Files
2026-07-13 21:36:47 +08:00

23 KiB
Raw Permalink Blame History

Multi-Context、弹窗与新窗口

适用场景:处理弹窗窗口、新标签页、OAuth 授权流程、支付网关跳转、多标签页协调,以及你的应用打开额外浏览器窗口或标签页的任何场景。 前置知识core/assertions-and-waiting.mdcore/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

相关文档