
當 Playwright 測試或爬蟲需要驗證時,通常沒有理由每次執行都從頭登入。常見做法是登入一次,用 storageState 把 Cookie 與相關瀏覽器狀態存下來,再在後續執行裡載入這份狀態以重用同一個工作階段。但有一份已保存的 storageState 檔案,並不代表登入會一直有效。工作階段可能過期、被伺服器撤銷,或一開始就沒有正確載入。
如果你自動化的是自己的帳號,而且日常瀏覽器裡已經登入過,還有另一條路:直接重用那個現成的瀏覽器工作階段。ego (lite) 讓 AI Agent 在已經具備所需登入狀態的瀏覽器裡工作,同時把每個任務隔離在各自的 Space 中。如果任務走到驗證碼、QR 登入或其他必須由你完成的步驟,你可以在看得見的瀏覽器裡接手,而不是去自動化 MFA。
無論走哪條路,目標都一樣:確認自動化真正拿到了有效的已驗證工作階段。一次執行突然回到登入頁、API 回傳 401,或登入後的元素一直不出現,看起來像三類不同問題:導覽、權限,或選擇器問題,其實它們可能都指向同一個壞掉的工作階段。下面各節會用一套可重複的本機環境,示範如何在 Playwright 裡保存、重用、核對並刷新驗證狀態。
Playwright 登入壞掉時是什麼樣子
在夾具執行裡,一次被撤銷的工作階段同時打出了全部三種症狀。伺服器端工作階段被清掉之後,重用已保存狀態的 context 被重新導向到登入頁,next 參數指回儀表板;對已驗證 JSON 端點的探測請求回傳 401;等待儀表板表格則在設定的 3 秒後逾時。
在公開的 the-internet 登入頁上,同一類失敗就是 bounce。沒有活工作階段就打開 /secure,會落到 /login,並出現紅條 You must login to view the secure area。這就是下面提示框裡的 URL 檢查:瀏覽器停在登入表,不是 locator 以為的那一頁。這張截圖就是這次 bounce。它不能證明點 Logout 刪掉了 storageState 檔案。the-internet 的 Logout 結束的是目前視窗工作階段;已經寫到磁碟的檔案,在 Cookie 本身死掉之前,仍可能再次打開 /secure。

第三種症狀才是昂貴的。locator 在登入後元素上逾時,並不代表 locator 寫錯了;它代表你正在看的頁面,並不是你以為的那一頁。上面那次執行裡,表格從未渲染,因為瀏覽器停在登入表單上。把它當成選擇器問題,只會去修補一個從未壞過的查詢。
驗證失敗背後的四類根因
驗證失敗會歸到四類原因,每一類的修法都不同。先分類再動手,才能避免修完又變成多寫一條 wait。
1. 狀態從未載入
沒有帶 storageState 建立的 context 一開始就是未登入,無論上一輪發生過什麼。在錯誤的時機保存檔案、相對路徑解析到別處,或在一個狀態不會傳給相依專案的 project 裡跑 setup,結果都一樣。判斷訊號是:一個全新 context 和失敗的那個表現完全相同,兩者都未登入。
2. Cookie 還在,但已經失效
工作階段 Cookie 可以存在,網域和旗標也都對,伺服器仍然可以拒絕它。工作階段 Cookie 有自己的生命週期,伺服器隨時可以撤銷,包括重新部署、改密碼或閒置逾時。Cookie 的 expires 欄位只是提示,不是保證:伺服器端工作階段可能更早死掉。夾具用一次顯式的 revoke 呼叫重現的,就是這種情況。
3. context 之間的隔離
Cookie 和 localStorage 屬於瀏覽器 context,不屬於瀏覽器本身。從一個 context 保存狀態,卻指望另一個設定不同的 context 共用它,會靜默失敗。並行 worker 也一樣:每個 worker 有自己的 context,因此每個 worker 都需要自己的狀態檔,或自己的登入步驟。這種隔離是特性。它也解釋了為什麼一份在某處有效的工作階段,到另一處就像消失了。
4. SSO 重新導向鏈
單一登入時,你真正要進的應用程式,很少是持有工作階段的那個來源。登入會重新導向到另一個網域上的身分提供者,提供者設定自己的 Cookie,再帶著 code 或 Token 把控制權交回應用程式。鏈還沒走完就保存狀態,只會抓到工作階段的一部分並丟掉 IdP Cookie,下一輪又會重放這段重新導向。可靠做法是等到最終應用程式 URL 和已驗證標記都出現再保存,讓狀態檔帶上鏈上每一個來源。
登入一次,並把狀態存對
登入一次的流程很短:導覽、驗證、確認已驗證狀態,再把 storageState 寫到檔案。確認這一步,才把能用的狀態檔和偶爾失效的狀態檔分開。
import { chromium } from "playwright";
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
await page.goto("https://app.example.com/login");
await page.fill("input[name=email]", process.env.APP_EMAIL);
await page.fill("input[name=password]", process.env.APP_PASSWORD);
await page.click("button[type=submit]");
// Wait for the authenticated destination, not just for the click to land.
await page.waitForURL("**/dashboard");
// Verify before saving: an authenticated element plus a 200 from the API.
await page.waitForSelector("#revenue-table");
const probe = await context.request.get("https://app.example.com/api/summary");
if (probe.status() !== 200) throw new Error("login did not take");
await context.storageState({ path: "playwright/.auth/user.json" });
await browser.close();在夾具執行裡,從打開登入頁到寫出狀態檔花了 119 毫秒。檔案裡有一枚 Cookie,也就是伺服器簽發的工作階段識別碼,探測回傳 200。這類數字屬於夾具本身,真正可遷移的是檢查的形狀:核對目標 URL,核對可見的已驗證元素,核對已驗證端點,然後才持久化。
下面這次 headed 執行用的是 the-internet.herokuapp.com,不是夾具儀表板。119 毫秒仍屬於夾具。截圖只證明同一套儲存形狀出現在公開登入頁:認證後的 /secure、可見的 Logout,以及正在核對 /tmp/pw-auth.json 的 Claude。

對測試套件來說,同一思路表現為一個產出檔案的 setup project,以及消費它的相依 project,這也是官方驗證指南所記錄的模式。對指令碼和爬蟲,上面的序列就是全部流程。無論哪種,這份檔案都是憑證:裡面是活的工作階段 Cookie,所以要像對待密碼一樣對待它,並且不要放進版本控制。
在測試與爬蟲中重用已保存的狀態
重用只是建立 context 時改一行。在指令碼裡把檔案傳給 context。在 Playwright 測試套件裡,按 project 或按測試檔設定,讓每個 worker 都從同一份已登入狀態起步。
// Script: create the context from the saved state.
const context = await browser.newContext({
storageState: "playwright/.auth/user.json",
});
// Test: use the state for this file (or configure it per project).
test.use({ storageState: "playwright/.auth/user.json" });在夾具執行裡,用已保存檔案新建的 context 在 6 毫秒內到達受保護的儀表板,沒有登入步驟,已驗證探測回傳 200 和預期載荷。真正有意思的不是這 6 毫秒,而是流程裡唯一改變的,只是 context 的狀態從哪來。
重用截圖是同一公開站點。新的 headed context 載入了 /tmp/pw-auth.json,跳過登入表,打開已有 Logout 的 /secure。6 毫秒仍是夾具計時。

值得弄清狀態檔會帶什麼、不會帶什麼。它包含該 context 接觸過的每個來源的 Cookie,以及按來源劃分的 localStorage 項目。它不包含 IndexedDB、session storage 或 service worker 狀態。把 Token 放在 IndexedDB 裡的應用程式,因此需要額外的擷取步驟或重新登入;指望 storageState 覆蓋它們,是幽靈失敗的常見來源。Cookie 本身受網域、路徑和旗標約束,說明見 MDN Cookie 指南 和 Set-Cookie 參考。
偵測過期或被撤銷的工作階段
最便宜的偵測器,是在昂貴工作開始前發一次已驗證請求。多數應用程式都有一個小端點,回傳目前使用者、計數或設定物件,它的狀態碼就是工作階段的狀態碼。一次請求只要毫秒級;抓到一半才發現失敗,會賠掉整輪執行。
async function sessionIsAlive(context) {
const probe = await context.request.get(
"https://app.example.com/api/summary",
{ failOnStatusCode: false },
);
return probe.status() === 200;
}
if (!(await sessionIsAlive(context))) {
await loginAndSaveState(context);
}不要只看 Cookie 的 expires 欄位。它只說明瀏覽器該何時停止傳送 Cookie,並不等於伺服器何時停止接受。伺服器端撤銷在你主動詢問之前是看不見的。夾具的 revoke 呼叫正是這種情況:Cookie 仍在且形態完好,伺服器卻回答 401。
自動重新驗證
自動重新驗證是一台小型狀態機:偵測、重新登入、刷新已保存狀態、把失敗步驟再跑一次,然後核對。上限很重要。一個在迴圈裡默默重新驗證的工作流程,會掩蓋密碼錯誤、帳號鎖定或登入頁改版,並且在此過程中還樂意製造流量。
async function withSession(page, context, task) {
if (!(await sessionIsAlive(context))) {
await loginAndSaveState(context, page); // refresh file after login
}
try {
return await task();
} catch (error) {
if (!(await sessionIsAlive(context))) {
await loginAndSaveState(context, page); // one retry, then fail loudly
return await task();
}
throw error;
}
}在夾具裡,偵測器看到 401,重新登入和核對在 99 毫秒內完成,刷新後的狀態檔再次恰好只有一枚工作階段 Cookie。執行結束時停在受保護的儀表板,表格可見,渲染了三列。一次重新驗證如果沒有這三項確認,就不能算完成。
如果多個 worker 共用一份狀態檔,它們可能在同一時刻發現過期,並搶著重新登入。補救是 single-flight 協調:第一個 worker 刷新檔案,其餘等待,或每個 worker 刷新自己的副本。機制和工作階段持久化問題相同,後者會出現在多個 Agent 對著同一瀏覽器設定檔(Profile)跑的時候。
多帳號與並行執行
隔離是按 context 的,所以規則是每個帳號或角色一份狀態檔、每個 worker 一個 context,沒有共用的可變工作階段。在夾具裡,兩次並行登入產生了兩枚不同的工作階段 Cookie,兩次探測在同一毫秒回傳 200,哪個 context 也看不見對方的工作階段。這是要有意保住的性質,因為它一旦壞掉,失敗模式很隱蔽:兩個帳號互相覆蓋狀態,測試卻以錯誤使用者通過。
同一條規則也會出現在真實瀏覽器裡。兩個 Space 就是兩個 context:一個可以停在新分頁空轉,另一個保住已登入的 Airbnb 工作階段,而不會像單一 headed 視窗那樣共用 Cookie。隔離才是重點。總覽只是讓你同時看見兩者。

核對驗證是否真正生效
核對是三項檢查,跳過任何一項,工作流程就會安靜地繼續壞著。第一項是正向:只在登入後才存在的元素出現了。第二項是負向:登入表單不在,因此碰巧兩者都有的頁面不會被接受。第三項是資料檢查,它會在過期或快取頁上失敗,例如兩次執行之間必須變化的值。
await page.waitForSelector("#revenue-table"); // positive
await expect(page.locator("#login-form")).toHaveCount(0); // negative
const probe = await context.request.get("/api/summary"); // data
assert.equal(probe.status(), 200);
assert.ok((await probe.json()).q3Revenue);夾具的重用執行三項都過了:表格可見,登入表單計數為零,JSON 載荷含有預期欄位。三項加起來只要幾毫秒,卻把有效工作階段和壞工作階段的差別,從推斷變成事實。對仍然表現異常的執行,更多除錯方法寫在 Playwright 除錯指南,以及最佳實務頁面則涵蓋周邊習慣,包括哪些等待值得寫。
當工作階段屬於你日常使用的瀏覽器
Playwright 的 storageState 檔是你自己掌控的東西。帳號是夾具、密碼在 CI secrets 裡、機器旁沒有人時,它是對的工具。登入是你自己的時,它就是錯的工具:SSO、硬體金鑰、推播提示、你日常 Chrome 裡已經保溫的工作階段。那種情況下,任務不是重建登入,而是借用已經擁有它的瀏覽器。
ego (lite) 就是這種借用。Agent 可以開啟你已經在用的已登入分頁,在不會搶走滑鼠的 Space 裡繼續工作,並在第二因素或付款確認需要你時停下來。工作階段永遠不會變成磁碟上的 JSON 檔。這才是重點:個人登入應該留在瀏覽器裡,而不是經過一條 CI 稍後可能誤提交的 storageState 路徑。

把兩套工具分開。無人值守的測試帳號繼續走 storageState,正如 Playwright 驗證指南所述。個人儀表板留在看得見的瀏覽器裡。ego (lite) 不會去點 MFA,不會確認付款,也不會收集憑證;這些停點寫在 Space 文件和 快速開始。目前產品版本是 0.5.0.32(changelog,2026-09-12)。再次核對更新紀錄 和 GitHub 存放庫 之後,再引用更新的建置。
FAQ
為什麼保存 storageState 之後,Playwright 仍會落到登入頁?
保存 storageState 之後仍落到登入頁,通常是因為檔案保存得太早、context 從未載入它,或伺服器撤銷了 Cookie。先看檔案裡的 Cookie,再看 context 選項。兩者都看起來沒問題時,按失效工作階段處理,而不是導覽 bug。
工作階段 Cookie 能隨 storageState 一起留下來嗎?
能。檔案會記錄 Cookie,無論它們有沒有過期時間,以及按來源劃分的 localStorage。留不下來的是瀏覽器放在這套結構之外的東西:IndexedDB、session storage、快取和 service worker。
保存下來的 Playwright 登入能用多久?
只要伺服器還接受這個工作階段,而那是伺服器自己的決定,隨時可以反悔。Cookie 的過期日期是上限,不是承諾。任何長期存放的狀態檔,在探測給出相反結論之前,都按過期處理。
並行 worker 能共用一份 storageState 檔嗎?
可以讀,而且每個 worker 的 context 是隔離的。問題出現在一個 worker 重新登入後刷新檔案,而另一個還在執行中。要麼給每個 worker 自己的副本,要麼把刷新序列化,保證同一時間只發生一次登入。
在 Playwright 裡如何用 OAuth 或 SSO 驗證?
把完整重新導向鏈走完一次,放在會保留身分提供者 Cookie 的 context 裡,並且只在到達應用程式最終 URL 之後再保存狀態。如果提供者需要互動步驟,在同一次執行裡手工做一次,之後重用得到的狀態。
MFA 和一次性驗證碼該怎麼處理?
不要用存下來的驗證碼去自動化第二因素。誠實的模式是一次性人工步驟,結果就是已保存工作階段,或你自己瀏覽器裡已經驗證過的工作階段。把 OTP 金鑰或簡訊驗證碼和狀態檔放在一起,等於把安全控制變成負擔。
把 storageState 提交進版本控制安全嗎?
不安全。檔案裡是活的工作階段 Cookie。把它留在工作目錄,在 git 裡忽略,CI 中從密鑰儲存注入。如果曾經被提交過,就把工作階段視為已外洩並撤銷。
如何有意測試自動化能否處理過期工作階段?
加一個在伺服器端讓工作階段失效的測試掛鉤,就像夾具的 revoke 端點那樣,再對它跑重用路徑。斷言重新導向、探測回傳 401,以及復原過程。這一條測試覆蓋的,是平時只在正式環境才會觸發的程式路徑。
如果應用程式把 Token 存在 IndexedDB 裡怎麼辦?
storageState 帶不走它,所以走兩條路之一:登入後用頁面指令碼擷取 Token,重用時再注入;或每次執行都透過簽發 Token 的 API 做驗證。前者更快,後者更少綁死應用程式內部實作。
是否每次執行都該重新驗證,而不是重用狀態?
對使用一次性測試帳號的 CI,每次執行重新驗證是更穩妥的預設。對頻繁對著穩定工作階段跑的工作,重用加探測、再加有上限的重新登入更便宜,也更好推理。兩者都合理;未經核對的重用不合理。
為什麼本機登入成功,CI 裡卻失敗?
通常是因為 CI 裡狀態檔缺失或更舊、時鐘不一致,或登入頁給新 IP 提供了不同變體。用同樣的無頭設定、viewport 和狀態檔重現,並在套件執行前探測工作階段。失敗往往是環境問題,不是程式碼差異。
一份 storageState 檔能服務兩個帳號嗎?
不能,而且試圖合併它們時,往往會留下最後寫入的那枚 Cookie。每個帳號或角色各用一份檔案,按所屬身分命名,再把正確的那份傳給每個 context。