# Multi-Context、弹窗与新窗口 > **适用场景**:处理弹窗窗口、新标签页、OAuth 授权流程、支付网关跳转、多标签页协调,以及你的应用打开额外浏览器窗口或标签页的任何场景。 > **前置知识**:[core/assertions-and-waiting.md](assertions-and-waiting.md)、[core/fixtures-and-hooks.md](fixtures-and-hooks.md) ## 快速参考 ```typescript // 捕获点击触发的弹窗 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** ```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** ```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](authentication.md)。 **TypeScript** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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** ```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](authentication.md)——通过存储的认证状态绕过 OAuth 弹窗 - [core/multi-user-and-collaboration.md](multi-user-and-collaboration.md)——多用户实时协作测试 - [core/file-operations.md](file-operations.md)——无弹窗的文件下载处理 - [core/third-party-integrations.md](third-party-integrations.md)——模拟 OAuth 提供方以避开真实弹窗 - [core/fixtures-and-hooks.md](fixtures-and-hooks.md)——多上下文设置的 fixture 方案