拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Playwright 多标签页、窗口与弹窗测试实战:从 Popup 处理到 OAuth 与跨标签同步(Sanity 仓库实践)

Playwright 多标签页、窗口与弹窗测试实战从 Popup 处理到 OAuth 与跨标签同步Sanity 仓库实践【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity本篇技术指南围绕 Playwright E2E 测试中**单用户多标签页Multi-Tab、多窗口Window与弹窗Popup**场景展开内容源自当前仓库内置的 playwright-best-practices 技能集 中 multi-context.md 参考文档。Sanity Studio 仓库的 e2e 测试套件 本身就大量实践了这些模式——例如 cookieAuth.spec.ts 与 dualAuth.spec.ts 中的跨标签页登出/登录同步测试。读完本文你将掌握如何可靠地捕获并驱动window.open产生的弹窗、如何测试target_blank新标签页、如何在弹窗中完成 OAuth 登录以及为什么推荐 Mock OAuth、如何验证多窗口之间的数据同步以及如何优雅地管理与清理标签页。说明本文聚焦单用户在同一浏览器会话BrowserContext内打开多个标签/窗口/弹窗的场景若需测试多用户同时在线协作不同登录态、实时协作、RBAC、并发操作请参阅姊妹文档 multi-user.md。章节概览Popup 处理新标签页导航OAuth 流程多窗口测试标签页协调与清理反模式速查表仓库实践Sanity Studio 的跨标签同步测试1. Popup 处理弹出窗口Popup是网页中通过window.open()打开的新浏览器窗口常见于支持聊天窗口连接第三方账号社交分享等场景。Playwright 中一个核心原则是所有自动化 API 都是异步的——弹窗不会在触发它的那一刻就可用因此必须在触发动作之前开始监听。1.1 基础弹窗处理正确写法是使用page.waitForEvent(popup)预先注册监听器再去点击触发弹窗的按钮最后await这个 Promise 拿到popup页面对象test(handle popup window, async ({page}) { await page.goto(/) // Start waiting for popup before triggering it const popupPromise page.waitForEvent(popup) await page.getByRole(button, {name: Open Support Chat}).click() const popup await popupPromise // Wait for popup to load await popup.waitForLoadState() // Interact with popup await popup.getByLabel(Message).fill(Need help) await popup.getByRole(button, {name: Send}).click() await expect(popup.getByText(Message sent)).toBeVisible() // Close popup await popup.close() })关键点拆解顺序不可颠倒waitForEvent(popup)必须写在click()之前否则弹窗已经弹出、事件已经错过测试会永久挂起。popup是一个完整的Page对象拥有与普通页面完全相同的定位器Locator、断言与交互 API可直接对其执行fill、click、getByRole等操作。弹窗打开后建议先popup.waitForLoadState()等待加载完成再进行交互。交互完毕后调用popup.close()主动关闭避免留下多余页面。1.2 带认证的弹窗连接账号授权第三方登录类弹窗的典型流程是在弹窗内完成登录 → 弹窗自动关闭 → 主页面反映已连接状态。测试时要先waitForEvent(popup)在弹窗中完成表单然后用popup.waitForEvent(close)等待弹窗自行关闭最后回到主页面断言结果test(popup login flow, async ({page}) { await page.goto(/dashboard) const popupPromise page.waitForEvent(popup) await page.getByRole(button, {name: Connect Account}).click() const popup await popupPromise await popup.waitForLoadState() // Complete login in popup await popup.getByLabel(Email).fill(userexample.com) await popup.getByLabel(Password).fill(password123) await popup.getByRole(button, {name: Log In}).click() // Popup should close automatically after auth await popup.waitForEvent(close) // Main page should reflect connected state await expect(page.getByText(Account connected)).toBeVisible() })这里waitForEvent(close)是验证认证成功后弹窗自动关闭这一产品行为的直接手段——它既等待了弹窗关闭这一异步事件也隐式验证了应用的关闭逻辑确实生效。1.3 处理被浏览器拦截的弹窗并非每次window.open都会被放行——浏览器弹窗拦截器Popup Blocker可能拦下它。稳健的测试必须同时覆盖弹窗打开与未打开两种情况test(handle popup blocker, async ({page}) { await page.goto(/share) // Listen for console messages about blocked popup page.on(console, (msg) { if (msg.text().includes(popup blocked)) { console.log(Popup was blocked) } }) const popupPromise page.waitForEvent(popup).catch(() null) await page.getByRole(button, {name: Share to Twitter}).click() const popup await popupPromise if (!popup) { // Popup blocked - app should show fallback await expect(page.getByText(Copy share link instead)).toBeVisible() } })这段代码展示了两个防御性技巧给waitForEvent(popup)挂上.catch(() null)当弹窗被拦截、事件永不触发时Promise 会 resolve 为null而不是让测试因超时而失败——把弹窗是否打开变成程序可判断的分支条件。分支断言弹窗打开时测试弹窗内容弹窗被拦截时断言应用提供了降级方案如复制分享链接。这要求被测应用确实实现了降级 UI否则应结合实际情况取舍。2. 新标签页导航与window.open弹窗不同target_blank链接打开的新标签页属于当前 BrowserContext 的既有页面Page因此监听对象是context而不是page。2.1 链接在新标签页中打开test(external link opens in new tab, async ({page, context}) { await page.goto(/resources) // Wait for new page in context const pagePromise context.waitForEvent(page) await page.getByRole(link, {name: Documentation}).click() const newPage await pagePromise await newPage.waitForLoadState() expect(newPage.url()).toContain(docs.example.com) await expect(newPage.getByRole(heading, {level: 1})).toBeVisible() // Original page still there expect(page.url()).toContain(/resources) await newPage.close() })要点事件源不同新标签页事件挂在context上context.waitForEvent(page)因为BrowserContext才管理着其下所有的页面实例。同样遵循先监听、后触发的顺序原则。可对newPage断言其 URL、标题等内容同时确认原页面依然存在且 URL 未变——这是验证新标签打开而非当前页跳转的关键。测试结束后newPage.close()清理。2.2 拦截新标签页改为同页导航有时候测试的目标其实是链接指向的落地页内容此时可以把target_blank属性移除让链接在当前标签页内导航从而简化断言只需要一个page对象test(prevent new tab for testing, async ({page}) { await page.goto(/links) // Remove target_blank to keep navigation in same tab await page.evaluate(() { document.querySelectorAll(a[target_blank]).forEach((a) { a.removeAttribute(target) }) }) // Now link opens in same tab await page.getByRole(link, {name: External Site}).click() // Can test the destination page await expect(page).toHaveURL(/external-site\.com/) })这里通过page.evaluate在页面上下文中批量移除所有a[target_blank]的target属性使链接在当前页导航之后就能用expect(page).toHaveURL(...)直接断言跳转结果。该技巧尤其适合目标页面位于第三方站点、不便开新页的测试场景但需注意这会改变应用的真实行为新标签不再打开若测试重点是新标签是否正确打开则应保留上一节的做法。3. OAuth 流程OAuth 是弹窗测试最典型的应用场景点击使用 Google 登录→ 弹出第三方授权窗口 → 输入凭据 → 重定向回本应用 → 弹窗关闭。针对这类流程文档给出了真实流程与Mock 流程两种方案并明确推荐后者。3.1 真实 Google OAuth 弹窗test(Google OAuth login, async ({page}) { await page.goto(/login) const popupPromise page.waitForEvent(popup) await page.getByRole(button, {name: Sign in with Google}).click() const popup await popupPromise await popup.waitForLoadState() // Handle Googles OAuth flow await popup.getByLabel(Email or phone).fill(testgmail.com) await popup.getByRole(button, {name: Next}).click() await popup.getByLabel(Enter your password).fill(password) await popup.getByRole(button, {name: Next}).click() // Wait for redirect back and popup close await popup.waitForEvent(close) // Verify logged in on main page await expect(page.getByText(Welcome, Test User)).toBeVisible() })真实 OAuth 流程的完整链路是弹窗内填写邮箱/密码 → 逐级点击下一步 → 授权后重定向回应用并自动关闭弹窗 → 主页面展示登录态。3.2 Mock OAuth推荐方案真实 OAuth 测试存在明显短板慢、不稳定flaky、依赖真实第三方凭据且 Google 等平台可能对自动化访问设限。因此文档强烈推荐用page.route拦截 OAuth 的回调与令牌交换端点模拟完整登录流程test(mock OAuth flow, async ({page, context}) { // Mock the OAuth callback instead of real flow await page.route(**/auth/callback**, async (route) { // Simulate successful OAuth const url new URL(route.request().url()) url.searchParams.set(code, mock-auth-code) await route.fulfill({ status: 302, headers: {Location: /dashboard}, }) }) // Mock token exchange await page.route(**/api/auth/token, (route) route.fulfill({ json: { access_token: mock-token, user: {name: Test User, email: testexample.com}, }, }), ) await page.goto(/login) await page.getByRole(button, {name: Sign in with Google}).click() // Should redirect to dashboard without actual OAuth await expect(page).toHaveURL(/dashboard) await expect(page.getByText(Welcome, Test User)).toBeVisible() })Mock 方案的核心思想用page.route拦截**/auth/callback**伪造一个携带code参数、302重定向到/dashboard的响应模拟OAuth 回调成功再拦截**/api/auth/token伪造令牌交换响应access_token 用户信息于是点击登录按钮后应用无需接触真实 OAuth 服务即可完成登录测试既快又稳定。3.3 OAuth Fixture 与 SSO关于更完整的 OAuth Mock 模式——自定义 fixture、多 ProviderGoogle/GitHub/Microsoft、SAML SSO——本文档将其归入第三方服务主题详见 third-party.md 的 OAuth/SSO Mocking 章节。该文档提供了mockOAuthfixture 的实现可按 provider 参数化地 mock 回调重定向与 session/user 端点以及 SAML ACS 断言的 Mock 示例。本节的定位是聚焦弹窗窗口本身的处理机制无论 Mock 与否waitForEvent(popup) → 弹窗交互 → waitForEvent(close) → 主页面断言这一弹窗驱动骨架始终不变。4. 多窗口测试4.1 跨窗口数据同步同一个BrowserContext下打开的多个窗口共享存储Cookie、LocalStorage、IndexedDB 等因此适合测试一个窗口修改、另一个窗口实时同步的应用行为test(sync between windows, async ({context}) { // Open two pages const page1 await context.newPage() const page2 await context.newPage() await page1.goto(/dashboard) await page2.goto(/dashboard) // Make change in first window await page1.getByRole(button, {name: Add Item}).click() await page1.getByLabel(Name).fill(New Item) await page1.getByRole(button, {name: Save}).click() // Should sync to second window (if app supports real-time sync) await expect(page2.getByText(New Item)).toBeVisible({timeout: 10000}) })该测试的断言基础是应用实现了实时同步如通过 BroadcastChannel、WebSocket、SSE 等机制。为了容纳同步延迟给toBeVisible显式传入{timeout: 10000}。若应用未实现实时同步需要手动刷新才能看到则对应改用page2.reload()后断言见下文标签页协调一节。4.2 不同窗口中的不同用户本文件覆盖的是单用户多窗口场景。若需要在不同窗口/上下文中模拟不同用户如管理员与普通用户交互、实时协作、基于角色的测试、并发操作请参阅 multi-user.md——该文档详细讲解了如何通过browser.newContext()创建隔离上下文、通过storageState加载不同登录态、以及如何使用多用户 fixture 管理会话生命周期。5. 标签页协调与清理5.1 在多个标签页之间切换典型的编辑 预览双标签页工作流可以用page.bringToFront()在标签页之间切换焦点test(manage multiple tabs, async ({context}) { const page1 await context.newPage() await page1.goto(/editor) const page2 await context.newPage() await page2.goto(/preview) // Edit in first tab await page1.bringToFront() await page1.getByLabel(Content).fill(Hello World) // Check preview in second tab await page2.bringToFront() await page2.reload() // If preview needs refresh await expect(page2.getByText(Hello World)).toBeVisible() })注意虽然 Playwright 的绝大多数操作并不要求标签页处于前台自动化不受可见性限制bringToFront()仍可用于模拟用户正在看哪个标签页的语义并对依赖于焦点/可见性的应用逻辑如懒加载、visibilitychange监听进行更真实的模拟。若预览不自动刷新可用page2.reload()主动刷新后再断言。5.2 关闭除主页面外的所有标签页多个弹窗/标签页打开后应在测试结尾统一清理避免资源泄漏test(cleanup tabs after test, async ({context}) { const mainPage await context.newPage() await mainPage.goto(/) // Open several popups during test for (let i 0; i 3; i) { const popup await context.newPage() await popup.goto(/popup/${i}) } // Close all except main page for (const page of context.pages()) { if (page ! mainPage) { await page.close() } } expect(context.pages()).toHaveLength(1) })这里用到了context.pages()——它返回当前上下文中所有存活的页面配合遍历即可精准地留一杀余最后用expect(context.pages()).toHaveLength(1)验证清理结果。6. 反模式速查表原文档将多标签/弹窗测试中最常见的错误总结为下表写作测试时建议对照自查反模式问题解决方案未等待弹窗Not waiting for popup竞态条件Race condition在触发动作之前使用waitForEvent(popup)测试真实 OAuthTesting real OAuth慢、不稳定、需要凭据Mock OAuth 端点假设弹窗必然打开Assuming popup opens弹窗可能被浏览器拦截同时处理打开与被拦截两种情况未关闭多余页面Not closing extra pages资源泄漏在清理阶段关闭页面其中未等待弹窗是所有 popup 相关 flaky 测试的头号根源——waitForEvent(popup)必须先于触发动作注册否则事件监听会错过弹窗打开瞬间导致测试挂起或超时。7. 仓库实践Sanity Studio 的跨标签同步测试以上模式并非纸上谈兵——当前仓库的 e2e 测试套件 就大量运用了同一 context 多标签页的测试手法可作为真实项目中的对照范例。7.1 基于 BroadcastChannel 的跨标签登出同步e2e/tests/auth/cookieAuth.spec.ts 中Cookie 认证跨标签同步测试组Cookie auth: cross-tab sync通过context.newPage()打开两个标签页验证在一个标签页登出/登录另一个标签页通过 BroadcastChannel 同步更新test(logout in one tab reflects in another tab via BroadcastChannel, async ({context}) { const page1 await context.newPage() const page2 await context.newPage() // Set up authenticated mocks for both pages const page1Auth await setupMockAuth(page1, {catchAll: true}) const page2Auth await setupMockAuth(page2, {catchAll: true}) // Load tabs sequentially to avoid broadcast races during init await page1.goto(STUDIO_URL) await expect(page1.locator([data-testidstudio-navbar])).toBeVisible() await page2.goto(STUDIO_URL) await expect(page2.locator([data-testidstudio-navbar])).toBeVisible() // Switch both mocks to unauthenticated before triggering logout. // Both pages may re-check /users/me after receiving the BroadcastChannel // message, so both mocks must return 401. page1Auth.logOut() page2Auth.logOut() // In page1: open user menu and click Sign out await page1.locator([iduser-menu]).click() await page1.getByText(Sign out).click() // Page1 should show the login screen await expect( page1.locator([data-uiHeading]:has-text(Choose login provider)), ).toBeVisible() // Page2 should also show login screen via BroadcastChannel sync await expect( page2.locator([data-uiHeading]:has-text(Choose login provider)), ).toBeVisible() })这段测试体现了几条值得借鉴的实战经验跨标签同步测试的天然适用性BroadcastChannel 是同源多标签页实时通信的标准机制Sanity Studio 用它实现多标签登录态同步而 Playwright 的context.newPage()打开的多个标签页天然共享同一 origin 与存储正是验证该机制的最小可行环境。顺序加载标签页规避广播竞态注释明确说明Load tabs sequentially to avoid broadcast races during init——两个标签页依次加载避免初始化阶段 BroadcastChannel 消息竞争导致的断言抖动。Mock 状态需同步切换两个页面在收到广播后都可能重新请求/users/me因此page1Auth.logOut()与page2Auth.logOut()必须同时把两个页面的 Mock 切到 401否则其中一个页面可能拿到不一致的认证响应。真实 UI 断言而非理想化选择器由于 Sanity UI 的Heading组件渲染为div而非h1–h6测试没有用getByRole(heading)而是用[data-uiHeading]:has-text(Choose login provider)组合定位——这与原文档反复强调的定位器要贴合真实 DOM 结构完全一致。同组的 dualAuth.spec.ts 还验证了登录后跨标签同步恢复在一个标签页重新登录另一标签页经 BroadcastChannel 同步恢复认证态其加载顺序与 Mock 同步的套路与上文一致可作为进阶对照。7.2 会话与配置层面的配套默认 e2e 配置 e2e/playwright.config.ts 中通过use.storageState为每个 context 预置认证态cookies: []localStorage中的__studio_auth_token_${PROJECT_ID}这正是为同一用户的多标签测试准备共享会话的典型做法同时配置了actionTimeout: 10000、expect.timeout: 30000、retries: 2与trace: on-first-retry从超时与失败诊断层面为弹窗/新标签等异步场景兜底。认证类测试单独使用 e2e/playwright.auth.config.ts自带 3340 端口的 dev server与默认配置3339 端口隔离避免多标签认证测试与其他套件互相干扰。e2e/globalSetup.ts 展示了browser.newContext(contextOptions)context.newPage()的底层用法即先有 context、后有 page的对象模型——理解这一点是掌握popup 事件在 page 上、新标签事件在 context 上的根本原因。相关参考认证模式多标签/弹窗中的认证初始化与会话处理参见 fixtures-hooks.md。网络 MockOAuth 端点的page.route拦截进阶用法参见 network-advanced.md。OAuth/SSO Mocking 全览fixture 化 OAuth、多 Provider 与 SAML参见 third-party.md。多用户协作测试多 context 隔离、RBAC、并发与实时协作参见 multi-user.md。实时同步底层机制WebSocket/SSE 等实时通道的 Mock 与测试参见 websockets.md。【免费下载链接】sanitySanity Studio – Rapidly configure content workspaces powered by structured content项目地址: https://gitcode.com/GitHub_Trending/sa/sanity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门