ego (lite) 只是一個瀏覽器,ego 則是你跨裝置的個人 Agent。
加入候補名單
Playwright驗證storageState瀏覽器自動化已登入工作階段

Playwright 驗證:登入一次、重用工作階段,過期後再重新驗證

2026年9月16日14 分鐘閱讀
像素風的 Playwright 角色與 ego (lite) 角色站在筆電登入畫面旁,畫面可見 Agent is in control

當 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。

請求受保護區域後的 the-internet.herokuapp.com/login,紅條寫著 You must login to view the secure area
沒有活工作階段就打開受保護頁,會 bounce 到 /login。紅條就是訊號。這是缺失或已死的工作階段,不是壞掉的 locator,也不是 Logout 刪掉了已儲存的 storageState 檔案。

第三種症狀才是昂貴的。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。

登入後的 the-internet.herokuapp.com/secure headed Chromium 視窗旁是 Claude Code,Logout 可見,Claude 正在核對 /tmp/pw-auth.json
登入後 headed Chromium 停在 /secure。Claude 正在核對 /tmp/pw-auth.json。後面的 context 重用的就是這個檔案。

對測試套件來說,同一思路表現為一個產出檔案的 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 毫秒仍是夾具計時。

Claude Code 用 /tmp/pw-auth.json 新建 Playwright context,沒有填寫登入表單,旁邊是 the-internet.herokuapp.com/secure,Logout 仍然可見
新的 headed context 載入了 storageState,直接打開 /secure。提示寫明不要填登入表;Logout 已經在頁面上。

值得弄清狀態檔會帶什麼、不會帶什麼。它包含該 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。隔離才是重點。總覽只是讓你同時看見兩者。

Claude Code 旁是 ego (lite) 的 Spaces 總覽:一個空轉的 Google Space,一個正在執行的已登入 Airbnb Space
空轉的 Google Space 旁邊是正在執行的已登入 Airbnb Space。隔離看得見;空轉分頁不是第二次已登入搜尋。

核對驗證是否真正生效

核對是三項檢查,跳過任何一項,工作流程就會安靜地繼續壞著。第一項是正向:只在登入後才存在的元素出現了。第二項是負向:登入表單不在,因此碰巧兩者都有的頁面不會被接受。第三項是資料檢查,它會在過期或快取頁上失敗,例如兩次執行之間必須變化的值。

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 路徑。

Claude Code 顯示已完成的 Airbnb 房源比較,旁邊是 airbnb.com.sg 上正在執行的 ego (lite) Space,可見 Agent is in control 和 Take over
已登入的 Airbnb 工作階段留在瀏覽器裡。Agent 在一個看得見 Agent is in control 和 Take over 的 Space 裡工作。

把兩套工具分開。無人值守的測試帳號繼續走 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。