QA How-To
Playwright popup and new tab handling: Examples and Best Practices
Use playwright popup and new tab handling examples for target blank links, OAuth, payments, multiple pages, first-request routing, closure, and helpers.
26 min read | 2,232 words
TL;DR
For every popup example, start the event wait before one trigger, await the returned Page, and assert destination-specific evidence. Use context routes for initial requests, close waits for self-closing flows, and URL or content identity for multiple pages.
Key Takeaways
- Capture each new Page with an event registered before its exact trigger.
- Verify destination identity and business state, not only that a tab exists.
- Wait for self-closing popups and then assert the opener's final state.
- Identify multiple pages by URL or content instead of arrival order.
- Register BrowserContext routes before opening when the initial request is mocked.
- Use separate contexts only when the scenario requires isolated users or sessions.
- Return typed Page objects from helpers and clean up listeners and temporary tabs.
These playwright popup and new tab handling examples cover the flows that break most often in real suites: target blank links, delayed window.open(), OAuth-style self-closing windows, payment redirects, unknown page creators, multiple tabs, opener messaging, initial-request mocking, cleanup, and reusable helpers. Every example captures the new Page before interacting with it.
The API pattern is consistent, but the verification changes with the business flow. A report tab should prove identity and content. An authorization popup should close itself and update its opener. A payment tab should preserve the expected session and return an outcome. The examples below focus on those contracts rather than generic window switching.
TL;DR
const newTabPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Open details' }).click();
const newTab = await newTabPromise;
await expect(newTab).toHaveURL(/\/details$/);
await expect(newTab.getByRole('heading', { name: 'Details' })).toBeVisible();
| Example | Capture API | Most important assertion |
|---|---|---|
| Known opener and trigger | page.waitForEvent('popup') |
Destination-specific content |
| Any page can create it | context.waitForEvent('page') |
Page identity by URL or UI |
| Self-closing authorization | Popup wait plus popup close wait | Opener shows connected state |
| Several pages open | Managed context.on('page') collector |
Identify by URL, not arrival order |
| First popup document is mocked | context.route() before trigger |
Initial document and app behavior |
1. Create a reusable example harness
Use Playwright Test's page and context fixtures. For self-contained examples, register BrowserContext routes for synthetic origins. Context routing is important because it can fulfill the initial navigation of a newly created popup.
import { test, expect, type BrowserContext, type Page } from '@playwright/test';
async function capturePopup(
opener: Page,
trigger: () => Promise<void>,
): Promise<Page> {
const popupPromise = opener.waitForEvent('popup');
await trigger();
return popupPromise;
}
async function mockHtml(
context: BrowserContext,
url: string,
body: string,
): Promise<void> {
await context.route(url, route => route.fulfill({
contentType: 'text/html',
body,
}));
}
The helper does one thing: it guarantees event-first sequencing around a trigger. It does not guess the last page in context.pages(), wait for a generic load state, or assert a destination. Callers retain responsibility for business evidence.
Route exact URLs when possible. A broad route such as **/* can accidentally replace application assets and hide integration failures. In production tests, use the real local or test application and mock only external dependencies that are outside the scenario's scope.
Keep Page variables descriptive, such as receipt, provider, or report, rather than page2. Names prevent accidental actions against the opener when a test moves between two interfaces.
2. Playwright popup and new tab handling examples for target blank links
A link with target="_blank" normally opens a Page related to the opener, so the opener's popup event is the most precise capture mechanism.
import { test, expect } from '@playwright/test';
test('opens product details in a new tab', async ({ page, context }) => {
await context.route('https://catalog.example.test/products/PW-42', route => {
return route.fulfill({
contentType: 'text/html',
body: `
<h1>Automation Toolkit</h1>
<dl><dt>SKU</dt><dd>PW-42</dd><dt>Status</dt><dd>In stock</dd></dl>
`,
});
});
await page.setContent(`
<a target="_blank" href="https://catalog.example.test/products/PW-42">
View Automation Toolkit
</a>
`);
const detailsPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'View Automation Toolkit' }).click();
const details = await detailsPromise;
await expect(details).toHaveURL(
'https://catalog.example.test/products/PW-42'
);
await expect(details.getByRole('heading', { name: 'Automation Toolkit' }))
.toBeVisible();
await expect(details.getByText('PW-42', { exact: true })).toBeVisible();
await expect(details.getByText('In stock', { exact: true })).toBeVisible();
});
The SKU assertion proves entity identity, not just successful navigation. If the product opens in the same tab on mobile by design, give that behavior a separate project-specific test rather than making this popup test accept either result silently.
Avoid selecting context.pages()[1]. A trace viewer, extension page, or another application action can make a numeric page index ambiguous. The event connects this Page to this link.
3. Capture delayed window.open without adding a sleep
An application may open a window after an asynchronous check. The event promise can remain active while the trigger completes. Do not insert a delay to guess when window.open() runs.
test('captures a preview opened after validation', async ({ page, context }) => {
await context.route('https://preview.example.test/document/7', route => {
return route.fulfill({
contentType: 'text/html',
body: '<h1>Document preview</h1><p>Version 7</p>',
});
});
await page.setContent(`
<button>Validate and preview</button>
<script>
document.querySelector('button').addEventListener('click', () => {
setTimeout(() => {
window.open('https://preview.example.test/document/7', '_blank');
}, 100);
});
</script>
`);
const previewPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Validate and preview' }).click();
const preview = await previewPromise;
await expect(preview.getByRole('heading', { name: 'Document preview' }))
.toBeVisible();
await expect(preview.getByText('Version 7')).toBeVisible();
});
The example's application has a 100 millisecond delay, but the test never copies it. waitForEvent() observes the actual browser event. In a real flow, assert validation success in the opener if that state is independently important.
If no popup appears, investigate whether the action failed validation, the browser blocked a non-user-initiated window, or the application navigated the current tab. Extending the wait does not fix an event that will never occur.
4. Test an OAuth-style popup that closes itself
An authorization flow usually has three observable phases: the opener launches the provider, the provider accepts or rejects access, and the popup closes while the opener reflects the result. This runnable example simulates those browser mechanics without claiming real-provider coverage.
test('connects an account through a self-closing popup', async ({ page, context }) => {
await context.route('https://app.example.test/settings', route => route.fulfill({
contentType: 'text/html',
body: `
<button>Connect provider</button><p role="status">Not connected</p>
<script>
document.querySelector('button').addEventListener('click', () => {
window.open('https://provider.example.test/authorize', '_blank');
});
addEventListener('message', event => {
if (event.origin === 'https://provider.example.test' && event.data === 'approved') {
document.querySelector('[role="status"]').textContent = 'Provider connected';
}
});
</script>
`,
}));
await context.route('https://provider.example.test/authorize', route => {
return route.fulfill({
contentType: 'text/html',
body: `
<h1>Authorize access</h1><button>Approve</button>
<script>
document.querySelector('button').addEventListener('click', () => {
window.opener.postMessage('approved', 'https://app.example.test');
window.close();
});
</script>
`,
});
});
await page.goto('https://app.example.test/settings');
const providerPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Connect provider' }).click();
const provider = await providerPromise;
await expect(provider.getByRole('heading', { name: 'Authorize access' }))
.toBeVisible();
const closePromise = provider.waitForEvent('close');
await provider.getByRole('button', { name: 'Approve' }).click();
await closePromise;
expect(provider.isClosed()).toBe(true);
await expect(page.getByRole('status')).toHaveText('Provider connected');
});
Add a rejection case that dismisses access and proves the opener remains disconnected. For real OAuth, never log callback URLs or tokens. The Playwright authentication guide covers reusable first-party session setup.
5. Verify a payment tab and return outcome
Payment tests should separate application wiring from external-provider certification. A routed provider page can prove the popup, order identity, shared session, callback, and opener result without making a real charge.
test('completes a mocked checkout tab', async ({ page, context }) => {
await context.route('https://pay.example.test/checkout/ORDER-81', route => {
return route.fulfill({
contentType: 'text/html',
body: `
<h1>Checkout ORDER-81</h1>
<p>Total $25.00</p><button>Pay now</button><p role="status"></p>
<script>
document.querySelector('button').addEventListener('click', () => {
document.querySelector('[role="status"]').textContent = 'Payment approved';
});
</script>
`,
});
});
await page.setContent(`
<button onclick="window.open('https://pay.example.test/checkout/ORDER-81')">
Pay order
</button>
`);
const checkoutPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Pay order' }).click();
const checkout = await checkoutPromise;
await expect(checkout.getByRole('heading', { name: 'Checkout ORDER-81' }))
.toBeVisible();
await expect(checkout.getByText('Total $25.00')).toBeVisible();
await checkout.getByRole('button', { name: 'Pay now' }).click();
await expect(checkout.getByRole('status')).toHaveText('Payment approved');
});
In a production application test, verify the first-party order state after the provider callback, not just provider text. Use synthetic payment credentials supplied by the provider's sandbox and never production card data.
If the payment page needs a different context, make that an explicit integration design. Ordinary popups inherit the opener's BrowserContext and related cookies under browser origin rules.
6. Capture a page when the opener is not known
Sometimes a workflow can create a page from an iframe, a secondary page, or background application logic. A BrowserContext page event provides the correct scope.
test('captures a page created anywhere in the context', async ({ page, context }) => {
await context.route('https://docs.example.test/help', route => route.fulfill({
contentType: 'text/html',
body: '<h1>Help center</h1>',
}));
await page.setContent(`
<button onclick="window.open('https://docs.example.test/help')">
Help
</button>
`);
const anyPagePromise = context.waitForEvent('page');
await page.getByRole('button', { name: 'Help' }).click();
const help = await anyPagePromise;
await expect(help).toHaveURL('https://docs.example.test/help');
await expect(help.getByRole('heading', { name: 'Help center' })).toBeVisible();
});
For this simple trigger, the opener popup event would be narrower. The example shows the context API for cases where the creator is genuinely uncertain.
Do not register both waits for the same action and then use whichever resolves first. Both events describe the same opener-created Page, and the unused wait complicates failure handling. Select the scope that expresses the requirement.
7. Identify multiple pages by behavior, not arrival index
If one workflow intentionally opens several pages, collect context page events and identify Pages by URL or content. Network scheduling can change their arrival order.
test('validates two generated report tabs', async ({ page, context }) => {
for (const month of ['june', 'july']) {
await context.route(`https://reports.example.test/${month}`, route => {
return route.fulfill({
contentType: 'text/html',
body: `<h1>${month[0].toUpperCase() + month.slice(1)} report</h1>`,
});
});
}
await page.setContent(`
<button onclick="
window.open('https://reports.example.test/june');
window.open('https://reports.example.test/july');
">Open reports</button>
`);
const opened: import('@playwright/test').Page[] = [];
const collect = (newPage: import('@playwright/test').Page) => opened.push(newPage);
context.on('page', collect);
await page.getByRole('button', { name: 'Open reports' }).click();
await expect.poll(() => opened.length).toBe(2);
const june = opened.find(item => item.url().endsWith('/june'));
const july = opened.find(item => item.url().endsWith('/july'));
expect(june).toBeDefined();
expect(july).toBeDefined();
await expect(june!.getByRole('heading')).toHaveText('June report');
await expect(july!.getByRole('heading')).toHaveText('July report');
context.removeListener('page', collect);
});
The count wait establishes that both events arrived, then URL identity replaces positional assumptions. Keep the non-null assertions after explicit existence checks. A production helper can throw a diagnostic error listing safe observed origins when a page is missing.
Multiple automatic windows may be blocked or poor UX, so confirm this is an intentional product contract. Sequential user actions with individual waits are easier to test and understand.
8. Mock the initial popup request at context level
If you attach a route after the popup event, the initial document request has already begun. Register the BrowserContext route before the opening action.
test('mocks the first document request', async ({ page, context }) => {
let initialRequests = 0;
await context.route('https://external.example.test/terms', async route => {
initialRequests += 1;
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<h1>Service terms</h1><p>Test-controlled copy</p>',
});
});
await page.setContent(`
<a href="https://external.example.test/terms" target="_blank">Read terms</a>
`);
const termsPromise = page.waitForEvent('popup');
await page.getByRole('link', { name: 'Read terms' }).click();
const terms = await termsPromise;
await expect(terms.getByRole('heading', { name: 'Service terms' }))
.toBeVisible();
expect(initialRequests).toBe(1);
});
Use route mocking for a controlled boundary, not to erase every dependency. If service workers control traffic, configure the context appropriately for a deterministic routing test and keep a separate test for the service-worker path.
For broader request design, see Playwright network interception examples.
9. Test opener communication and relationship
Some popups call window.opener.postMessage(), while others rely on storage or server callbacks. Assert both the relationship and the validated message result where opener communication is the contract.
const popupPromise = page.waitForEvent('popup');
await page.getByRole('button', { name: 'Start verification' }).click();
const verification = await popupPromise;
expect(await verification.opener()).toBe(page);
await verification.getByRole('button', { name: 'Complete verification' }).click();
await expect(page.getByRole('status')).toHaveText('Identity verified');
Application code must validate event.origin; a test should include a negative case for an untrusted origin at a lower layer when messaging is security-sensitive. Do not weaken production origin checks merely to make route-based examples convenient.
If rel="noopener" is part of a target blank link, the browser may intentionally remove the JavaScript opener relationship for security. The Page can still be observed through the BrowserContext. Test the user-visible workflow and the intended security property, not an opener reference the markup deliberately prevents.
The API security testing basics provide a useful framework for separating UI wiring from authorization enforcement.
10. Close temporary tabs and protect later assertions
Close a Page explicitly when it is temporary and the application is not responsible for closing it. Verify application-driven closure through the close event instead.
const preview = await capturePopup(page, async () => {
await page.getByRole('button', { name: 'Open preview' }).click();
});
await expect(preview.getByRole('heading', { name: 'Preview' })).toBeVisible();
await preview.close();
expect(preview.isClosed()).toBe(true);
await expect(page.getByRole('button', { name: 'Publish' })).toBeEnabled();
Do not reuse locators from a closed Page. Subsequent actions fail because their Page no longer exists. Keep popup-scoped objects inside the phase where the Page is open.
When closing the opener first is part of a resilience scenario, remember that popup.opener() returns null if the opener is already closed. Assert recovery behavior through supported navigation or server state rather than assuming an opener remains available.
Cleanup matters in large suites. Extra pages consume memory, emit events, and can confuse later context-wide collectors. Playwright closes every Page when its test context ends, but explicit cleanup improves long multi-page scenarios and clarifies ownership.
Also verify that closing a secondary Page does not accidentally terminate the BrowserContext. A normal popup close should leave the opener usable, its cookies intact, and its pending workflow in a defined state. After cleanup, perform one meaningful assertion or action on the opener rather than checking only isClosed(). That catches implementations that tied essential state to the popup process or failed to handle cancellation.
For cancellation behavior, create a separate test: open the popup, close it without completing, then assert the opener shows a retry path or unchanged status. Do not reuse the successful-flow test with a conditional branch. Success and cancellation have different outcomes, and separate reports make failures much easier to diagnose.
11. Build a typed business helper or popup page object
Helpers should capture the Page adjacent to the trigger and return it. Assertions belong in the test or a destination-specific component unless every caller shares the same invariant.
import type { Page } from '@playwright/test';
export class ReportsPage {
constructor(private readonly page: Page) {}
async openReport(name: string): Promise<Page> {
const reportPromise = this.page.waitForEvent('popup');
await this.page.getByRole('link', { name: `Open ${name} report` }).click();
return reportPromise;
}
}
const report = await new ReportsPage(page).openReport('quality');
await expect(report).toHaveURL(/\/reports\/quality$/);
await expect(report.getByRole('heading', { name: 'Quality report' }))
.toBeVisible();
For a rich destination, wrap the captured Page in a ReportPopup class. Pass the Page through its constructor and expose business actions. Avoid mutable singleton storage such as this.latestTab, which becomes ambiguous when multiple tabs open or tests run in parallel.
Name the method after the outcome. openReport() communicates more than switchWindow(). It also preserves Playwright's model, where the test already owns the Page reference and no global switch exists.
12. Review playwright popup and new tab handling examples
Start with event timing. The wait must be registered before the exact action that creates the Page, and the returned Page must be used rather than an array position. Next, check scope: opener popup for a known relationship, context page for true context-wide discovery, and a managed listener only for multiple or unknown events.
Review destination evidence. A URL alone may redirect correctly while content or authentication is wrong. Require a stable heading, entity ID, amount, status, or action. For self-closing flows, require the close event and the opener's updated state. For payment or identity providers, state clearly whether routing proves application wiring or a real integration.
Inspect network placement and session assumptions. Initial-document routing belongs to the BrowserContext before the trigger. Same-context Pages share session state; separate users need separate contexts. Ensure listener cleanup, popup cleanup, safe logging, and trace retention.
Finally, remove sleeps, generic networkidle waits, unconditional bringToFront(), and Selenium-style window helpers. Run relevant browsers and verify responsive behavior through explicit projects. A strong example fails clearly when no Page opens, the wrong entity opens, the destination fails, or the opener never receives completion.
Interview Questions and Answers
Q: Show the safest basic pattern for a target blank link.
Create const popupPromise = page.waitForEvent('popup'), click the link, then await the promise. Assert the new Page's URL and meaningful content. This registers the listener before the browser can emit the event.
Q: Why is context.pages().at(-1) weaker?
It assumes the newest array member belongs to the trigger and that nothing else opened. The popup event provides direct causality. Page inventory remains useful for debugging or explicit multi-page assertions.
Q: How would you test OAuth completion?
I capture the provider Page, interact with the controlled provider or sandbox, register a close wait before approval, and await self-closure. Then I assert first-party connected state in the opener and avoid logging tokens or callback parameters.
Q: How do you handle two pages that may open in either order?
I collect context page events until the expected count is reached, then identify Pages by URL or destination content. Arrival index is not a business identity. I remove the listener after collection and close pages no longer needed.
Q: Where should a route for the popup's first page be registered?
On the BrowserContext before the trigger. The popup Page is surfaced only after its first response starts, so Page-level routing added afterward cannot intercept that initial request.
Q: What does popup.opener() tell you?
It returns the opener Page when available, or null if there is no opener or it has already closed. I assert it only when the relationship is part of the contract, especially because noopener can intentionally remove JavaScript opener access.
Q: What should a popup helper return?
It should normally return the captured Page or a destination-specific object constructed from it. It should keep event timing beside the trigger and avoid selecting global last-page state. Callers can then assert their own business destination.
Common Mistakes
- Waiting for the popup only after the opening action completes.
- Picking a Page from
context.pages()by array position. - Naming helpers
switchWindowand reintroducing a handle-switching model. - Asserting only that some new Page exists, without destination identity.
- Using arrival order to identify multiple pages.
- Adding fixed sleeps for delayed
window.open()calls. - Attaching a Page route too late for the initial popup request.
- Forgetting the opener and popup share one BrowserContext session.
- Creating separate contexts for tabs that should share authentication.
- Manually closing a popup whose self-closure is the product behavior.
- Leaving context page listeners active after collection.
- Logging sensitive provider URLs, tokens, or payment details.
Conclusion
The most useful playwright popup and new tab handling examples preserve causality: register the event, perform one trigger, capture the resulting Page, and verify a destination-specific outcome. From there, tailor the evidence to the workflow, including self-closure and opener updates for authorization, entity and amount checks for payment, or identity-based collection for multiple pages.
Keep initial routing at context level, session boundaries explicit, listeners managed, and temporary pages closed. Replace last-page guessing and sleeps in one existing test with an event-first helper, then add the business assertion that would catch the wrong tab.
Interview Questions and Answers
Write the event sequence for a reliable popup test.
Create the popup wait from the opener, perform exactly one opening action, and await the resulting Page. Then assert destination URL and business content. If closure is expected, register another event wait before the closing action.
How would you test an asynchronous window.open implementation?
I register the event before the user action and let it remain pending through the application's async work. I assert any meaningful opener validation, then validate the captured destination. I avoid fixed sleeps because the browser event is the true synchronization point.
How do you keep an OAuth popup test secure?
I use synthetic accounts or a controlled provider, never production credentials. I avoid logging callback URLs, authorization codes, and tokens. I verify first-party state and keep real-provider integration coverage small and environment-appropriate.
How do you identify pages in a multi-popup workflow?
I collect context events until the required count and map Pages by safe URL patterns or unique UI content. I never treat the first or last arrival as identity. I clean up the listener and unnecessary Pages after assertions.
What does context-level routing solve for popups?
It can intercept the new Page's initial navigation because the route exists before creation. A route attached after the popup event is too late for that request. Context routing also provides one controlled boundary for all Pages in the session.
How would you test postMessage communication from a popup?
I capture both Page references, perform the popup action that sends the message, and assert the opener's visible state. I keep production origin validation enabled and add negative origin coverage at the appropriate layer. The test should not directly mutate opener state.
What makes a good popup page-object method?
It starts the event wait immediately before the business trigger and returns the captured Page or a typed destination object. It does not select the last context page or hide arbitrary sleeps. Its name describes the business outcome, such as `openInvoice`.
Frequently Asked Questions
What is the best Playwright new tab example?
Start `page.waitForEvent('popup')`, click the target blank link, await the returned Page, and assert its URL plus a stable heading or entity identifier. This directly associates the Page with the link.
How do I test a delayed window.open call?
Register the popup event promise before clicking and await it after the action. The event wait can span the application's asynchronous validation, so the test should not copy the delay into `waitForTimeout()`.
How do I test an OAuth popup in Playwright?
Capture the provider Page, use a controlled provider or sandbox, start a close-event wait before approval, and await self-closure. Then assert the first-party connected state and keep callback secrets out of logs.
How do I handle multiple new tabs in Playwright?
Collect BrowserContext page events until the expected number arrives, then identify each Page by URL or content. Do not assume event arrival order equals business order, and remove the collector afterward.
Can a popup Page access its opener in Playwright?
`await popup.opener()` returns the opener Page when available. It can return null when there is no opener, the opener closed, or security behavior such as noopener removes the relationship.
How do I close a new tab after a Playwright test?
Call `await newTab.close()` when cleanup is the test's responsibility, then check `isClosed()` if needed. If application-driven closure is under test, wait for the close event instead of closing it manually.
Should a popup helper store the latest tab globally?
No. Return the captured Page or a typed destination object. Global latest-tab state becomes ambiguous with multiple events and parallel tests, while a returned object preserves ownership.
Related Guides
- How to Use Playwright popup and new tab handling (2026)
- Playwright dialog handling: Examples and Best Practices
- Playwright download handling: Examples and Best Practices
- Playwright drag and drop: Examples and Best Practices
- Playwright mock date and time: Examples and Best Practices
- Playwright tag and grep filters: Examples and Best Practices