23 KiB
Multi-Context、弹窗与新窗口
适用场景:处理弹窗窗口、新标签页、OAuth 授权流程、支付网关跳转、多标签页协调,以及你的应用打开额外浏览器窗口或标签页的任何场景。 前置知识:core/assertions-and-waiting.md、core/fixtures-and-hooks.md
快速参考
// 捕获点击触发的弹窗
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")
// 列出浏览器上下文中的所有页面
const allPages = context.pages()
console.log(`打开的标签页数: ${allPages.length}`)
核心概念:在 Playwright 中,"popup"(弹窗)指通过 window.open()、target="_blank" 链接或 JavaScript 触发的新窗口所打开的任意新页面。它们都会在原始页面上触发 popup 事件。
模式
处理基本弹窗
适用场景:用户操作打开新标签页或新窗口,且你需要与其交互。
避免场景:"弹窗"实际上是同一页面内的模态对话框——此时应使用 getByRole('dialog')。
TypeScript
import { test, expect } from "@playwright/test"
test('处理由 target="_blank" 链接打开的弹窗', 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")
expect(popup.url()).toContain("/docs")
// 操作完成后关闭弹窗
await popup.close()
})
test("处理由 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()
// 原始页面仍然可访问
await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test('处理由 target="_blank" 链接打开的弹窗', 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 弹窗流程
适用场景:你的应用打开第三方 OAuth 窗口(Google、GitHub、Microsoft 等)进行身份验证。 避免场景:你可以直接注入认证令牌来绕过 OAuth——参见 core/authentication.md。
TypeScript
import { test, expect } from "@playwright/test"
test("Google OAuth 弹窗流程", async ({ page }) => {
await page.goto("/login")
// 在点击 OAuth 按钮前监听弹窗
const popupPromise = page.waitForEvent("popup")
await page.getByRole("button", { name: "Sign in with Google" }).click()
const oauthPopup = await popupPromise
// 等待 OAuth 提供方页面加载
await oauthPopup.waitForLoadState()
expect(oauthPopup.url()).toContain("accounts.google.com")
// 在 OAuth 提供方页面上填写凭据
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()
// 弹窗在授权后自动关闭
// 等待原始页面收到认证回调
await page.waitForURL("/dashboard")
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible()
})
test("GitHub OAuth 弹窗流程", 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()
// 如果提示,则授权应用
const authorizeButton = popup.getByRole("button", { name: "Authorize" })
if (await authorizeButton.isVisible({ timeout: 3000 }).catch(() => false)) {
await authorizeButton.click()
}
// 弹窗关闭,原始页面重定向
await page.waitForURL("/dashboard")
await expect(page.getByText(/Welcome/)).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("GitHub OAuth 弹窗流程", 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()
})
支付网关弹窗
适用场景:结账流程在新窗口中打开支付提供商(PayPal、3D Secure 验证)。
避免场景:支付网关在同一页面内以 iframe 加载——此时应使用 frameLocator。
TypeScript
import { test, expect } from "@playwright/test"
test("PayPal 弹窗结账流程", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Email").fill("buyer@example.com")
await page.getByRole("button", { name: "Proceed to payment" }).click()
// PayPal 在弹窗中打开
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")
// 完成 PayPal 流程
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()
})
test("3D Secure 验证弹窗", async ({ page }) => {
await page.goto("/checkout")
await page.getByLabel("Card number").fill("4000000000003220") // 3DS 测试卡号
await page.getByLabel("Expiry").fill("12/28")
await page.getByLabel("CVC").fill("123")
await page.getByRole("button", { name: "Pay" }).click()
// 3DS 质询在弹窗或 iframe 中打开——两者都处理
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 {
// 回退:3DS 在 iframe 中
const frame = page.frameLocator('iframe[name*="challenge"]')
await frame.getByRole("button", { name: "Complete authentication" }).click()
}
await expect(page.getByText("Payment successful")).toBeVisible()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("PayPal 弹窗结账流程", 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()
})
多标签页协调
适用场景:测试多个标签页共享状态的场景——实时协作、购物车同步或跨标签页的会话管理。 避免场景:每个标签页相互独立,可以在单独的测试用例中测试。
TypeScript
import { test, expect } from "@playwright/test"
test("购物车更新跨标签页同步", async ({ context }) => {
// 在同一浏览器上下文中打开两个标签页(共享 Cookie/存储)
const page1 = await context.newPage()
const page2 = await context.newPage()
await page1.goto("/products")
await page2.goto("/cart")
// 在标签页 1 中添加商品
await page1.getByRole("button", { name: "Add to cart" }).first().click()
// 标签页 2 应反映更新(通过 WebSocket、轮询或存储事件)
await expect(page2.getByTestId("cart-count")).toHaveText("1", { timeout: 5000 })
})
test("在一个标签页中登出,所有标签页均登出", async ({ context }) => {
const page1 = await context.newPage()
const page2 = await context.newPage()
// 两个标签页均处于已认证页面
await page1.goto("/dashboard")
await page2.goto("/settings")
// 从标签页 1 登出
await page1.getByRole("button", { name: "Log out" }).click()
await page1.waitForURL("/login")
// 标签页 2 应在下次操作或自动跳转至登录页
await page2.reload()
expect(page2.url()).toContain("/login")
})
test("使用 context.pages() 管理多个标签页", async ({ context, page }) => {
await page.goto("/dashboard")
// 打开几个新标签页
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
// 列出此上下文中的所有页面
const allPages = context.pages()
expect(allPages).toHaveLength(3) // 原始页面 + 2 个弹窗
// 与特定标签页交互
await tab1.waitForLoadState()
await expect(tab1.getByRole("heading")).toContainText("Report A")
await tab2.waitForLoadState()
await expect(tab2.getByRole("heading")).toContainText("Report B")
// 操作完成后关闭标签页
await tab2.close()
await tab1.close()
expect(context.pages()).toHaveLength(1)
})
JavaScript
const { test, expect } = require("@playwright/test")
test("购物车更新跨标签页同步", 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("使用 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)
})
新标签页中的下载触发
适用场景:通过打开新标签页触发下载(例如,在新窗口中打开 PDF 后再下载)。
避免场景:下载直接开始而不打开新标签页——应直接使用 page.waitForEvent('download')。
TypeScript
import { test, expect } from "@playwright/test"
test("从新标签页下载 PDF", async ({ page }) => {
await page.goto("/invoices")
// 某些应用在新标签页中打开 PDF,然后触发下载
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")
const path = await download.path()
expect(path).toBeTruthy()
await popup.close()
})
test("导出在新标签页中打开并自动下载", async ({ page }) => {
await page.goto("/reports")
// 处理两种情况:弹窗下载 以及 弹窗展示内容
const popupPromise = page.waitForEvent("popup")
await page.getByRole("button", { name: "Export CSV" }).click()
const popup = await popupPromise
// 尝试捕获下载;若无下载,则弹窗展示内容
try {
const download = await popup.waitForEvent("download", { timeout: 5000 })
const filename = download.suggestedFilename()
expect(filename).toMatch(/\.csv$/)
await download.saveAs(`./downloads/${filename}`)
} catch {
// 无下载事件——内容展示在弹窗中
const content = await popup.textContent("body")
expect(content).toContain("Report Data")
}
await popup.close()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("从新标签页下载 PDF", 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()
})
多上下文隔离会话
适用场景:测试不同用户之间的交互(例如,管理员与普通用户、两个聊天参与者)。 避免场景:你只需要单个用户视角——单个上下文就足够了。
TypeScript
import { test, expect } from "@playwright/test"
test("管理员实时看到用户变化", 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()
})
JavaScript
const { test, expect } = require("@playwright/test")
test("管理员实时看到用户变化", 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()
})
决策指南
| 场景 | 方法 | 原因 |
|---|---|---|
target="_blank" 链接 |
page.waitForEvent('popup') |
Playwright 对所有新窗口/标签页都会触发 popup 事件 |
window.open() 调用 |
page.waitForEvent('popup') |
无论窗口如何打开,机制相同 |
| OAuth 登录弹窗 | waitForEvent('popup') + 交互 + 等待关闭 |
OAuth 提供方重定向回来并关闭弹窗 |
| 支付弹窗(PayPal) | waitForEvent('popup') + 完成流程 |
与 OAuth 类似,但具有支付特定的 UI |
| 新标签页中的下载 | popup.waitForEvent('download') |
在弹窗页面上捕获下载事件 |
| 同一用户的多个标签页 | 在同一 context 中打开页面 |
共享 Cookie、localStorage、session |
| 多个用户(独立会话) | 为每个用户创建独立的 browser.newContext() |
隔离的 Cookie、存储、认证状态 |
| 标签页同步测试 | 多个 context.newPage() + 断言共享状态 |
测试实时状态同步 |
| 可能弹出也可能不弹出的弹窗 | 带 try/catch 的 waitForEvent('popup', { timeout }) |
优雅处理条件式弹窗 |
反模式
| 不要这样做 | 问题 | 应这样做 |
|---|---|---|
先点击再调用 waitForEvent('popup') |
弹窗可能在监听器注册之前就已打开(竞态条件) | 在点击之前设置 waitForEvent |
使用 context.pages()[1] 获取弹窗 |
索引顺序不保证;脆弱 | 使用 waitForEvent('popup') 返回确切的页面 |
忘记调用 popup.waitForLoadState() |
弹窗页面可能尚未加载完成就进行交互 | 收到弹窗后始终调用 waitForLoadState() |
| 测试结束后不关闭弹窗 | 泄漏的页面消耗内存,可能影响后续测试 | 在测试中使用 popup.close() 关闭弹窗,或使用 fixture |
在标签页应共享状态时使用独立的 browser.newContext() |
独立上下文有独立的 Cookie/会话 | 同一会话中的标签页使用 context.newPage() |
为隔离用户使用 context.newPage() |
同一上下文中的页面共享状态 | 为独立用户会话使用 browser.newContext() |
弹窗触发后使用 page.waitForTimeout() |
弹窗时机不可预测 | 使用 page.waitForEvent('popup'),在弹窗打开时立即解析 |
| 在错误的页面上捕获弹窗 | popup 事件在触发该事件的页面上触发 |
在发生点击/操作的页面上监听 popup |
故障排查
| 症状 | 可能原因 | 修复方法 |
|---|---|---|
waitForEvent('popup') 超时 |
操作没有打开新窗口;而是在同一标签页内导航 | 检查链接是否有 target="_blank" 或是否调用了 window.open |
| 弹窗已打开但为空白 | 弹窗仍在加载;你过早进行了交互 | 在交互前添加 await popup.waitForLoadState() |
弹窗 URL 为 about:blank |
弹窗创建时为空,然后由 JavaScript 导航 | 使用预期的 URL 模式等待 popup.waitForURL() |
| OAuth 弹窗被浏览器拦截 | 弹窗拦截器处于启用状态 | Playwright 默认禁用弹窗拦截;检查是否有浏览器参数重新启用了它 |
| 两个弹窗打开,但只捕获到一个 | waitForEvent 只为第一个事件解析 |
使用 page.on('popup', callback) 捕获所有弹窗,或串联两次 waitForEvent 调用 |
context.pages() 返回的页面数量不符合预期 |
之前的测试留下了打开的页面 | 在 afterEach 中关闭所有额外页面,或使用每个测试独立的上下文 |
| 弹窗在交互完成前关闭 | 应用或 OAuth 提供方在超时后自动关闭 | 提高测试速度或移除 slowMo;立即与弹窗交互 |
| 跨域弹窗交互失败 | 弹窗导航到了不同的域 | Playwright 支持跨域弹窗;确保你没有设置 --disable-web-security |
相关文档
- core/authentication.md——通过存储的认证状态绕过 OAuth 弹窗
- core/multi-user-and-collaboration.md——多用户实时协作测试
- core/file-operations.md——无弹窗的文件下载处理
- core/third-party-integrations.md——模拟 OAuth 提供方以避开真实弹窗
- core/fixtures-and-hooks.md——多上下文设置的 fixture 方案