在 Day 17 我們處理了下拉選單和 Dialog/Modal的測試:它們會蓋住畫面,但元素仍在原本的網頁裡。Day 18 的 iframe 則是在同一個分頁裡進入另一份文件。
今天要處理的情境是使用者點擊連結後,瀏覽器會開出另一個新的分頁。在 Playwright 裡,這代表我們需要取得一個新的 Page 物件,之後的操作與斷言也必須從這個新物件開始。
小提醒:Playwright 將由目前頁面開出的新分頁稱為 popup,而不是 Day 17 那種 UI 彈窗。
目前的 react-admin demo 沒有 target="_blank" 或 window.open() 的現成功能;Dashboard 上的外部連結也會在原分頁導覽。因此,這次和 Day 18 一樣,新增一個很小的本地 fixture 來練習瀏覽器行為。
練習情境如下:
已登入的 react-admin 後台(原分頁)
└── 多分頁練習頁
└──「開新分頁查看訂單摘要」
└── 訂單摘要(新分頁)
新增 demo/src/multi-page/MultiPageDemo.tsx:
import { Button, Card, CardContent, Typography } from '@mui/material';
const MultiPageDemo = () => (
<Card>
<CardContent>
<Typography variant="h5" component="h1" gutterBottom>
多分頁練習:訂單摘要
</Typography>
<Typography paragraph>
點擊連結後,會在瀏覽器的新分頁開啟訂單摘要。
</Typography>
<Button
component="a"
href="/order-preview.html"
target="_blank"
rel="noopener noreferrer"
>
開新分頁查看訂單摘要
</Button>
</CardContent>
</Card>
);
export default MultiPageDemo;
target="_blank" 是讓瀏覽器建立一張新分頁。rel="noopener noreferrer" 是外開頁面常見的安全設定,避免新頁透過 window.opener 操作原頁面。
新增 demo/public/order-preview.html:
<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8" />
<title>訂單摘要</title>
</head>
<body>
<main>
<h1>訂單摘要</h1>
<p>訂單編號:RA-2026-001</p>
<p>金額:NT$ 1,280</p>
<button id="confirm" type="button">確認已閱讀</button>
<p id="result" role="status"></p>
</main>
<script>
const confirmButton = document.querySelector('#confirm');
const result = document.querySelector('#result');
confirmButton.addEventListener('click', () => {
result.textContent = '訂單摘要已確認';
});
</script>
</body>
</html>
在 demo/src/App.tsx 先加入 import:
import MultiPageDemo from './multi-page/MultiPageDemo';
再在既有的 CustomRoutes 區塊加入:
<Route path="/multi-page-demo" element={<MultiPageDemo />} />
啟動 demo、登入後開啟 http://localhost:8000/#/multi-page-demo。先手動點擊連結,確認瀏覽器真的多出一張分頁,並確認「確認已閱讀」按鈕會顯示結果文字,接著再開始寫測試。
當連結使用 target="_blank" 時,瀏覽器 context 會同時有兩張分頁:
BrowserContext
├── page 原本的多分頁練習頁
└── newPage 新開的訂單摘要頁
在這次的範例中,原頁面是 /#/multi-page-demo,新頁面是 /order-preview.html,所以 URL 的確不同。不過,URL 不同並不是判斷 Page 不同的決定性依據,因為兩個瀏覽器分頁也可以載入相同 URL;同一個分頁也可以導覽到不同 URL。
對 Playwright 來說,當開出新分頁的時候,原本的 page 與新分頁的 DOM 彼此獨立,所以不能用原本的 page 去尋找新分頁裡的元素。
例如下面這段會失敗:
await page.getByRole('link', { name: '開新分頁查看訂單摘要' }).click();
await expect(
page.getByRole('heading', { name: '訂單摘要' })
).toBeVisible();
點擊後,原本的 page 仍在多分頁練習頁;但 訂單摘要 標題屬於另一張分頁。
建立 playwright-tests/tests/day19-multi-page.spec.ts:
import { test, expect } from '../fixtures/fixtures';
test('可以在新分頁確認訂單摘要', async ({ loggedInPage: page }) => {
await page.goto('/#/multi-page-demo');
// 先建立等待,暫時不要 await。
const newPagePromise = page.waitForEvent('popup');
await page
.getByRole('link', { name: '開新分頁查看訂單摘要' })
.click();
const newPage = await newPagePromise;
await newPage.waitForLoadState();
// newPage 與原本的 page 是不同的 Page 物件。
expect(newPage).not.toBe(page);
await expect(newPage).toHaveURL(/order-preview\.html/);
await expect(
newPage.getByRole('heading', { name: '訂單摘要' })
).toBeVisible();
await newPage.getByRole('button', { name: '確認已閱讀' }).click();
await expect(newPage.getByRole('status')).toHaveText('訂單摘要已確認');
});
執行測試:
npx playwright test day19-multi-page --project=chromium
這個測試有四個重點:
page.waitForEvent('popup') 先建立一個 Promise,等待「由目前這張頁面開出的新分頁」的事件。click() 之前建立;如果先點擊,新分頁可能已經開出,之後才開始等待就會因為等不到這個event而 timeout。newPage 是訂單摘要所在的頁面,所以標題、按鈕與 status 都要從 newPage 開始定位。expect(newPage).not.toBe(page) 直接確認兩者不是同一個 Page 物件;URL 斷言只是在確認連結導向正確內容,不是用來定義分頁身分。另外我們用 waitForLoadState() 等待新頁面進入可操作狀態,而不是用 waitForTimeout() 去猜測需要等待幾秒,因為頁面載入速度不固定,用固定時間等待會不穩定。
popup event 和 page event 的範圍看到 popup 這個名稱,很容易誤以為它和「彈窗 UI」有關。實際上它描述的是目前這張 page 開出新分頁的這個事件。
target="_blank" 動作實際上會觸發兩件事:
目前的 page 開出新分頁
├── 目前的 page 發出 popup event
└── BrowserContext 發出 page event
兩種等待寫法都會取得新 Page,差別在監聽範圍:
| 寫法 | 監聽範圍 | 適合情境 |
|---|---|---|
page.waitForEvent('popup') |
目前這張頁面開出的新分頁 | 已知點擊來源就是目前頁面;本篇主案例 |
context.waitForEvent('page') |
這個 BrowserContext 裡任何新開的分頁 | 不知道哪一張頁面會開新分頁 |
我們的例子是明確知道是多分頁練習頁上的連結開出訂單摘要,所以選擇範圍較小、語意也更清楚的 popup event就足夠了。
另外,waitForEvent() 不論等待哪一種 event,都是一次性等待:它收到第一個符合條件的事件就會結束。若要持續記錄每一次事件,才會使用 page.on() 或 context.on();這屬於長期監聽,不在今天我們討論的範疇。
我們再為訂單摘要頁加入一條連結,來驗證 context.waitForEvent() 的監聽範圍。
<a href="/order-detail.html" target="_blank" rel="noopener noreferrer">
開新分頁查看訂單明細
</a>
並建立一個只含 <h1>訂單明細</h1> 的 demo/public/order-detail.html。
把下面的測試程式碼加入剛剛使用的playwright-tests/tests/day19-multi-page.spec.ts,然後跑跑看這個測試:
test('context 可以取得不同頁面開出的新分頁', async ({ loggedInPage: page }) => {
await page.goto('/#/multi-page-demo');
const context = page.context();
// A 開 B:兩個 event 都會取得同一個訂單摘要頁。
const popupFromA = page.waitForEvent('popup');
const pageFromContext = context.waitForEvent('page');
await page.getByRole('link', { name: '開新分頁查看訂單摘要' }).click();
const [orderPreview, sameOrderPreview] = await Promise.all([
popupFromA,
pageFromContext,
]);
expect(orderPreview).toBe(sameOrderPreview);
// B 開 C:由 context 繼續等待,不依賴是哪一張 page 開出的。
const detailFromContext = context.waitForEvent('page');
await orderPreview
.getByRole('link', { name: '開新分頁查看訂單明細' })
.click();
const orderDetail = await detailFromContext;
await expect(
orderDetail.getByRole('heading', { name: '訂單明細' })
).toBeVisible();
// C 的 DOM 不屬於 A;從原本的 page 找不到訂單明細標題。
await expect(
page.getByRole('heading', { name: '訂單明細' })
).not.toBeVisible();
});
最後的負向斷言驗證了 Page 的邊界:orderDetail(C)能找到 訂單明細,但原本的 page(A)不能。若改成下面這種正向斷言,測試會因為 A 的 DOM 裡根本沒有該標題而 timeout:
// 不要放進正式測試:這是刻意會失敗的寫法。
await expect(
page.getByRole('heading', { name: '訂單明細' })
).toBeVisible();
當 A 開 B 時,A 的 popup event 與 context 的 page event 都能取得 B;但由 B 開 C 時,context 仍能取得 C,而 A 無法直接操作 C 的 DOM。
今天處理的是瀏覽器新增分頁的情境:
page 的 UI;target="_blank" 才會建立新的 Page。page.waitForEvent('popup'),再點擊連結。newPage 後,所有 locator、操作和斷言都要從 newPage 開始。Page。context.waitForEvent('page') 的監聽範圍更大,適合來源不確定的新分頁。下一篇會處理檔案上傳與下載,它們同樣需要在正確的時機等待瀏覽器事件。