
当 Playwright 测试或爬虫需要认证时,通常没有理由每次运行都从头登录。常见做法是登录一次,用 storageState 把 Cookie 和相关浏览器状态存下来,再在后续运行里加载这份状态以复用同一会话。但有一份已保存的 storageState 文件,并不等于登录会一直有效。会话可能过期、被服务端吊销,或者一开始就没有正确加载。
如果你自动化的是自己的账号,而且日常浏览器里已经登录过,还有另一条路:直接复用那个现成的浏览器会话。ego (lite) 让 AI Agent 在已经具备所需登录态的浏览器里工作,同时把每个任务隔离在各自的 Space 中。如果任务走到验证码、扫码登录或其他必须由你完成的步骤,你可以在可见浏览器里接手,而不是去自动化 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。