<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Browser Automation on 扎塔-Zata</title><link>https://www.zata.cc/tags/browser-automation/</link><description>Recent content in Browser Automation on 扎塔-Zata</description><generator>Hugo -- gohugo.io</generator><language>zh-cn</language><copyright>Example Person</copyright><atom:link href="https://www.zata.cc/tags/browser-automation/index.xml" rel="self" type="application/rss+xml"/><item><title>扩展式 RPA：从 Chrome 扩展原理到 Playwright 实战方案</title><link>https://www.zata.cc/p/%E6%89%A9%E5%B1%95%E5%BC%8Frpa%E4%BB%8Echrome%E6%89%A9%E5%B1%95%E6%8E%A5%E7%AE%A1%E5%88%B0playwright%E5%AE%9E%E6%88%98/</link><pubDate>Mon, 27 Jul 2026 10:00:00 +0800</pubDate><guid>https://www.zata.cc/p/%E6%89%A9%E5%B1%95%E5%BC%8Frpa%E4%BB%8Echrome%E6%89%A9%E5%B1%95%E6%8E%A5%E7%AE%A1%E5%88%B0playwright%E5%AE%9E%E6%88%98/</guid><description>&lt;img src="https://www.zata.cc/p/%E6%89%A9%E5%B1%95%E5%BC%8Frpa%E4%BB%8Echrome%E6%89%A9%E5%B1%95%E6%8E%A5%E7%AE%A1%E5%88%B0playwright%E5%AE%9E%E6%88%98/images/index/index.svg" alt="Featured image of post 扩展式 RPA：从 Chrome 扩展原理到 Playwright 实战方案" />&lt;h2 id="一个观察">一个观察
&lt;/h2>&lt;p>之前在 [[../L../Library/Python_Lib/playwright/index.md]] 里写过 Playwright 自己起隔离浏览器的常规打法。但在看到一些 RPA 工具的形态后，问题就变成另一个：&lt;/p>
&lt;blockquote>
&lt;p>为什么有些 RPA 工具是装成 Chrome 扩展，一打开就能直接「操控」用户已经登录好的页面？&lt;/p>
&lt;/blockquote>
&lt;p>答案并不复杂：这两种工具在底层就是两套完全不同的思路。&lt;/p>
&lt;h2 id="核心原理谁拥有那个浏览器">核心原理：谁拥有那个浏览器
&lt;/h2>&lt;p>普通 Playwright(或者类似 Puppeteer、Selenium)启动的是&lt;strong>全新的、独立的浏览器实例&lt;/strong>，和你日常用的 Chrome 没有任何关系。Cookies 是空的，需要重新登录或注入 &lt;code>storage_state&lt;/code>。&lt;/p>
&lt;p>而 Chrome 扩展型 RPA 长这样：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">┌──────────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 你的 Chrome 浏览器（同进程） │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ┌────────────┐ ┌────────────────────┐ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ Tab: 微博 │ │ Extension: RPA 工具 │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ (已登录) │ │ 注入 content script │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └────────────┘ └────────────────────┘ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ↑ │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └────────────────┘ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 共享同一个 cookie 存储 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 共享同一个 origin 权限 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└──────────────────────────────────────────┘
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>扩展里跑的内容脚本 (&lt;code>content script&lt;/code>) 语法上是独立脚本，但它&lt;strong>直接被注入到登录页面的 DOM 里&lt;/strong>，运行在该页面的 JS context 中。所以当它调用 &lt;code>fetch('/api/user/info')&lt;/code> 时，浏览器自动带上微博的 cookies，服务器认为这就是用户本人。&lt;/p>
&lt;p>整件事可以浓缩成一句话：&lt;/p>
&lt;blockquote>
&lt;p>扩展是装在你浏览器里的代码，你在浏览器里登录了什么，它就拥有什么。&lt;/p>
&lt;/blockquote>
&lt;p>这也是为什么做需要登录态的 RPA 任务，扩展方案往往比 Playwright 简单十倍 —— 因为它根本不需要解决「登录」这个问题。&lt;/p>
&lt;h3 id="几块关键拼图">几块关键拼图
&lt;/h3>&lt;p>扩展形式看起来神秘，其实就是下面这几块常规 MV3 API 拼起来的：&lt;/p>
&lt;p>&lt;code>manifest.json&lt;/code> 声明权限：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-json" data-lang="json">&lt;span class="line">&lt;span class="cl">&lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;permissions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;cookies&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;storage&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;tabs&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;scripting&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;activeTab&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nt">&amp;#34;host_permissions&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;*://*.weibo.com/*&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;*://*.example.com/*&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">]&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>content.js&lt;/code> 注入目标页面，所有 &lt;code>fetch&lt;/code> 自动带登录态：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">autoPost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">content&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;#editor&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">content&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;#submit&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">chrome&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">runtime&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">onMessage&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addListener&lt;/span>&lt;span class="p">((&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sender&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">sendResponse&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">cmd&lt;/span> &lt;span class="o">===&lt;/span> &lt;span class="s1">&amp;#39;post&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">autoPost&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">msg&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">text&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">sendResponse&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">ok&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="kc">true&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>后台脚本 &lt;code>background.js&lt;/code> 里直接读 cookie：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-javascript" data-lang="javascript">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">chrome&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">cookies&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">getAll&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">domain&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;.weibo.com&amp;#39;&lt;/span> &lt;span class="p">},&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">cookies&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">console&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">log&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">cookies&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="c1">// 直接拿到用户的所有登录态
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>把上面三块加一个 &lt;code>popup.html&lt;/code>，就是最简版「扩展式 RPA」。&lt;/p>
&lt;h3 id="两种方案对比">两种方案对比
&lt;/h3>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>维度&lt;/th>
&lt;th>Playwright / 独立 RPA&lt;/th>
&lt;th>扩展式 RPA&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>启动方式&lt;/td>
&lt;td>独立进程，自己的浏览器&lt;/td>
&lt;td>跑在用户已有的 Chrome&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>登录态&lt;/td>
&lt;td>无，需要自登录或注入 &lt;code>storage_state&lt;/code>&lt;/td>
&lt;td>&lt;strong>天然继承&lt;/strong>用户浏览器的登录态&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>反爬检测&lt;/td>
&lt;td>高（指纹、IP、行为模式）&lt;/td>
&lt;td>低（就是用户本人）&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>用户授权&lt;/td>
&lt;td>无感（代码里随便用）&lt;/td>
&lt;td>需要用户主动装扩展&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>部署难度&lt;/td>
&lt;td>简单，纯代码&lt;/td>
&lt;td>需要打包扩展、上架商店或 crx 安装&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>资源占用&lt;/td>
&lt;td>完整浏览器进程&lt;/td>
&lt;td>几乎为零&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>适用场景&lt;/td>
&lt;td>批量任务、服务器端&lt;/td>
&lt;td>辅助个人用户、低频任务&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="反爬角度的思考">反爬角度的思考
&lt;/h3>&lt;p>扩展式 RPA 看起来「开挂」，但也不是万能：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>指纹检测&lt;/strong>：&lt;code>window.chrome.runtime&lt;/code> 等扩展特征可以被检测&lt;/li>
&lt;li>&lt;strong>行为检测&lt;/strong>：点击速度、操作序列如果太机械，仍能被识别&lt;/li>
&lt;li>&lt;strong>数据回流&lt;/strong>：云端 SaaS 形态的 RPA 在网络层会被分析&lt;/li>
&lt;li>&lt;strong>扩展权限&lt;/strong>：Chrome 在安装时展示权限，敏感权限可能让用户警觉&lt;/li>
&lt;li>&lt;strong>Manifest V3 限制&lt;/strong>：MV3 收紧了远程代码执行、跨域请求，扩展型 RPA 的能力被削弱&lt;/li>
&lt;/ol>
&lt;h3 id="商业产品参考">商业产品参考
&lt;/h3>&lt;ul>
&lt;li>&lt;strong>UI.Vision (Kantu)&lt;/strong>：经典老牌，扩展形式，直接读用户登录态&lt;/li>
&lt;li>&lt;strong>Browserflow&lt;/strong>：Chrome 商店里很常见&lt;/li>
&lt;li>&lt;strong>Axiom.ai&lt;/strong>：扩展 + 云端&lt;/li>
&lt;li>&lt;strong>影刀 / 八爪鱼&lt;/strong>：部分功能走扩展模式，处理需要登录的网站&lt;/li>
&lt;li>&lt;strong>Automa&lt;/strong>（开源）：GitHub 上有源码，可以学到完整的实现&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="用-playwright-模拟扩展式-rpa">用 Playwright 模拟扩展式 RPA
&lt;/h2>&lt;p>虽然「真扩展」的形态在 Playwright 这边不好复刻（Playwright 是独立进程，无法直接读取用户 Chrome 的 cookies），但有一种折中方案：让 Playwright &lt;strong>复用用户已登录的浏览器&lt;/strong>。技术上叫 &lt;strong>Persistent Context&lt;/strong>。&lt;/p>
&lt;p>核心 API 是 &lt;code>launch_persistent_context&lt;/code>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 关键：指向真实 Chrome 的用户数据目录&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># macOS : ~/Library/Application Support/Google/Chrome&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Windows: %LOCALAPPDATA%\Google\Chrome\User Data&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch_persistent_context&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_data_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;/Users/zata/Library/Application Support/Google/Chrome&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">channel&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s1">&amp;#39;chrome&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># 强制用系统 Chrome，不是 chromium&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1"># 必须可视化（见下面）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">no_viewport&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;--disable-blink-features=AutomationControlled&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s1">&amp;#39;--no-first-run&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;https://weibo.com&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 此时你已经是登录状态了，直接用&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.follow-btn&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">first&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">click&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="但这条路的坑很大">但这条路的坑很大
&lt;/h3>&lt;p>和同仓库那篇 [[../L../Library/Python_Lib/playwright/index.md]] 的警告一致 —— 复用真实 Chrome 的 Profile 风险极高：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>必须先完全关闭 Chrome&lt;/strong>：用户数据目录是文件锁定的，Chrome 在开的时候 Playwright 启动会直接报 &lt;code>Failed to create a ProcessSingleton for your profile&lt;/code>。&lt;/li>
&lt;li>&lt;strong>&lt;code>headless=False&lt;/code> 是硬需求&lt;/strong>：不是「看得到」这么简单的诉求，而是 headless 模式下被检测概率会显著上升，自动化特征也更明显。&lt;/li>
&lt;li>&lt;strong>User Data 目录要用整个 &lt;code>User Data&lt;/code>，而不是 &lt;code>Default&lt;/code>&lt;/strong>。&lt;/li>
&lt;li>&lt;strong>不要做 &lt;code>rm -rf&lt;/code> 这种清理动作&lt;/strong>：容易连带覆盖或损坏历史、书签。&lt;/li>
&lt;/ol>
&lt;p>写个辅助函数降低出错的可能：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">kill_chrome&lt;/span>&lt;span class="p">():&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="s2">&amp;#34;&amp;#34;&amp;#34;杀进程,确保数据目录不被 Chrome 自己锁住&amp;#34;&amp;#34;&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;pkill&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;-9&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;Google Chrome&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">check&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="实际可运行的完整脚本">实际可运行的完整脚本
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># rpa.py&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">subprocess&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">import&lt;/span> &lt;span class="nn">time&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pathlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">CHROME_PATH&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;/Applications/Google Chrome.app/Contents/MacOS/Google Chrome&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">USER_DATA&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">home&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="s1">&amp;#39;Library/Application Support/Google/Chrome&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">ensure_chrome_closed&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">subprocess&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">run&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;pkill&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;-9&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;-f&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;Google Chrome&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">check&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">2&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">def&lt;/span> &lt;span class="nf">run&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ensure_chrome_closed&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># Playwright 启动一个临时 Chrome 进程,加载你的用户数据&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch_persistent_context&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_data_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="nb">str&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">USER_DATA&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">executable_path&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">CHROME_PATH&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">no_viewport&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;--disable-blink-features=AutomationControlled&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">ignore_default_args&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;--enable-automation&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">try&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pages&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pages&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;https://weibo.com&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 验证:这里应该能直接看到登录态&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">assert&lt;/span> &lt;span class="s1">&amp;#39;登录&amp;#39;&lt;/span> &lt;span class="ow">not&lt;/span> &lt;span class="ow">in&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">title&lt;/span>&lt;span class="p">(),&lt;/span> &lt;span class="s1">&amp;#39;用户态异常,可能 Chrome 未正常启动&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 开始自动化...&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;https://weibo.com/u/1234567890&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;text=关注&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">first&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">click&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">print&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;✅ 关注成功&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">finally&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">time&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">sleep&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">1&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">if&lt;/span> &lt;span class="vm">__name__&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s1">&amp;#39;__main__&amp;#39;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">run&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="进阶cdp-远程连接">进阶：CDP 远程连接
&lt;/h3>&lt;p>&lt;code>launch_persistent_context&lt;/code> 有一个尴尬的体验：用户在自己用 Chrome，你想跑 RPA，必须先杀 Chrome 再启动一次。更优雅的方案是 &lt;strong>Chrome DevTools Protocol（CDP）远程连接&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="c1"># 第一步：用户在终端手动启动 Chrome（带远程调试）&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">google-chrome --remote-debugging-port&lt;span class="o">=&lt;/span>&lt;span class="m">9222&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 第二步：Playwright 接管这个 Chrome,而不是新启&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">p&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">connect_over_cdp&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;http://localhost:9222&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">contexts&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="c1"># 复用用户的 context&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pages&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="c1"># 接管用户当前的 tab&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;https://weibo.com&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>CDP 方案的好处：&lt;/p>
&lt;ul>
&lt;li>不需要杀 Chrome&lt;/li>
&lt;li>用户当前打开的 tab 可以直接被我们接管&lt;/li>
&lt;li>跑完可以「无缝」还给用户&lt;/li>
&lt;/ul>
&lt;h3 id="playwright--chrome-extension">Playwright + Chrome Extension
&lt;/h3>&lt;p>如果目标是&lt;strong>让用户装一个扩展，由扩展来驱动云端 Playwright&lt;/strong>，逻辑反过来：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">┌─────────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 用户 Chrome │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── 你的 Extension (MV3) │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ │ └── WebSocket 客户端 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── 普通网页 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── 被扩展的 content script 操控 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└────────────┬────────────────────────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ ws://localhost:8765
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">┌─────────────────────────────────────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 你的 Python 服务 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── WebSocket Server │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── Playwright(可选,云端兜底) │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─────────────────────────────────────────┘
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这种架构适合做 &lt;strong>SaaS 化的 RPA&lt;/strong>：扩展做「采集 + 可视化」，云端 Playwright 做「批量执行」。&lt;/p>
&lt;hr>
&lt;h2 id="方案选型表">方案选型表
&lt;/h2>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>场景&lt;/th>
&lt;th>推荐方案&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>个人脚本，偶尔跑一下&lt;/td>
&lt;td>&lt;code>launch_persistent_context&lt;/code> + 杀 Chrome&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>个人脚本，经常跑&lt;/td>
&lt;td>CDP 远程连接 + 一个一键启动 Chrome 的 &lt;code>.command&lt;/code> 文件&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>工具化，要给同事用&lt;/td>
&lt;td>CDP 方案 + 配启动器&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>做产品，要分发&lt;/td>
&lt;td>写 Chrome 扩展（完全独立，无需 Playwright）&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>前三种方案都没跳出 Playwright 的能力范围，基本能覆盖 90% 的需求。最后一种是真正的「扩展式 RPA」，但投入就大很多了 —— 它本身就是一个独立的产品形态，从 manifest、签名、商店审核到 MV3 限制都是另一套工程。&lt;/p>
&lt;p>如果是从零开始想把 RPA 能力给到个人用户，&lt;strong>先从 CDP 方案做起&lt;/strong>，把脚本跑通、把页面录制做成、把回放做成；等到扩展的能力真的有必要了，再考虑写 MV3 分发。&lt;/p>
&lt;h2 id="参考资料">参考资料
&lt;/h2>&lt;ul>
&lt;li>&lt;a class="link" href="https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-persistent-context" target="_blank" rel="noopener"
>Playwright: &lt;code>launch_persistent_context&lt;/code>&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://playwright.dev/python/docs/other-ports#connect-over-cdp" target="_blank" rel="noopener"
>Playwright: Connect over CDP&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://developer.chrome.com/docs/extensions/develop/migrate/what-is-mv3" target="_blank" rel="noopener"
>Chrome Extensions: Manifest V3 Migration&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://developer.chrome.com/docs/extensions/reference/api/cookies" target="_blank" rel="noopener"
>Chrome Extensions: Cookies API&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://github.com/rrweb-io/rrweb" target="_blank" rel="noopener"
>rrweb / rrweb-player&lt;/a>（会话录制的开源参考实现）&lt;/li>
&lt;li>&lt;a class="link" href="https://www.automa.site/" target="_blank" rel="noopener"
>Automa - Chrome 扩展式 RPA 开源实现&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>浏览器会话录制与接口回放：方案调研</title><link>https://www.zata.cc/p/%E6%B5%8F%E8%A7%88%E5%99%A8%E4%BC%9A%E8%AF%9D%E5%BD%95%E5%88%B6%E4%B8%8E%E6%8E%A5%E5%8F%A3%E5%9B%9E%E6%94%BE%E6%96%B9%E6%A1%88%E8%B0%83%E7%A0%94/</link><pubDate>Mon, 27 Jul 2026 00:00:00 +0000</pubDate><guid>https://www.zata.cc/p/%E6%B5%8F%E8%A7%88%E5%99%A8%E4%BC%9A%E8%AF%9D%E5%BD%95%E5%88%B6%E4%B8%8E%E6%8E%A5%E5%8F%A3%E5%9B%9E%E6%94%BE%E6%96%B9%E6%A1%88%E8%B0%83%E7%A0%94/</guid><description>&lt;img src="https://www.zata.cc/p/%E6%B5%8F%E8%A7%88%E5%99%A8%E4%BC%9A%E8%AF%9D%E5%BD%95%E5%88%B6%E4%B8%8E%E6%8E%A5%E5%8F%A3%E5%9B%9E%E6%94%BE%E6%96%B9%E6%A1%88%E8%B0%83%E7%A0%94/images/index/index.svg" alt="Featured image of post 浏览器会话录制与接口回放：方案调研" />&lt;h2 id="背景与目标">背景与目标
&lt;/h2>&lt;p>想做这样一个工具：&lt;/p>
&lt;ol>
&lt;li>用户打开一个目标网站&lt;/li>
&lt;li>在浏览器里手动操作（点击、输入、滚动、跳转）&lt;/li>
&lt;li>后台实时记录每一步 DOM 事件 + 每个 HTTP 接口的请求/响应&lt;/li>
&lt;li>操作结束后，能看到一份按时间线排列的&amp;quot;操作 + 接口&amp;quot;清单，必要时还能重放&lt;/li>
&lt;/ol>
&lt;p>这类能力在业内通常叫 &lt;strong>Session Replay / 用户会话录制&lt;/strong>。&lt;/p>
&lt;h2 id="核心原理">核心原理
&lt;/h2>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">┌─────────────┐ 事件 / 网络上报 ┌──────────┐
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ 浏览器注入 │ ────────────────────&amp;gt; │ 后端 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ SDK / 扩展 │ │ 落库 │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└─────────────┘ └──────────┘
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ │
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> │ DOM 快照 + 事件流 ▼
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─────────────────────&amp;gt; 回放页面
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> (rrweb-player 等)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>关键三件事：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>DOM 事件捕获&lt;/strong>：&lt;code>click&lt;/code> / &lt;code>input&lt;/code> / &lt;code>scroll&lt;/code> / &lt;code>navigation&lt;/code>，通常用 &lt;code>addEventListener&lt;/code> 全局委托 + 事件冒泡&lt;/li>
&lt;li>&lt;strong>网络拦截&lt;/strong>：浏览器侧 &lt;code>XMLHttpRequest&lt;/code> / &lt;code>fetch&lt;/code> 包装一层钩子，或者用 Service Worker / 浏览器扩展的 &lt;code>webRequest&lt;/code> API&lt;/li>
&lt;li>&lt;strong>DOM 变化记录&lt;/strong>：&lt;code>MutationObserver&lt;/code> 监听 DOM 增量变化，配合初始快照实现像素级回放&lt;/li>
&lt;/ul>
&lt;h2 id="现有方案">现有方案
&lt;/h2>&lt;h3 id="商业产品开箱即用">商业产品（开箱即用）
&lt;/h3>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>工具&lt;/th>
&lt;th>特点&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>SessionStack&lt;/strong>&lt;/td>
&lt;td>像素级回放 + 错误捕获 + 性能分析，企业级&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>FullStory&lt;/strong>&lt;/td>
&lt;td>数字体验分析，强在漏斗和搜索&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>LogRocket&lt;/strong>&lt;/td>
&lt;td>类 SessionStack，回放 + Redux/Zustand 状态回放&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Hotjar&lt;/strong>&lt;/td>
&lt;td>更偏热力图和漏斗&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>&lt;strong>Microsoft Clarity&lt;/strong>&lt;/td>
&lt;td>免费，基础回放 + 热力图&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h3 id="开源方案">开源方案
&lt;/h3>&lt;ul>
&lt;li>&lt;strong>&lt;a class="link" href="https://github.com/rrweb-io/rrweb" target="_blank" rel="noopener"
>rrweb&lt;/a>&lt;/strong> — 最主流的开源前端录制库，用 &lt;code>MutationObserver&lt;/code> + 事件监听完整还原 DOM 变化，配套有 &lt;code>rrweb-player&lt;/code> 回放器。LogRocket 早期也借鉴过类似思路&lt;/li>
&lt;li>&lt;strong>rrweb-snapshot / rrweb-player&lt;/strong> — 序列化和回放子包&lt;/li>
&lt;li>&lt;strong>Playwright Trace Viewer&lt;/strong> / &lt;strong>Patchright&lt;/strong> — 如果场景是&amp;quot;复现自动化流程&amp;quot;，Playwright 自带的 trace 就能记录每一步点击、请求、截图&lt;/li>
&lt;/ul>
&lt;h2 id="自己从零做的思路">自己从零做的思路
&lt;/h2>&lt;h3 id="最简-mvp">最简 MVP
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">浏览器扩展
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ content script 注入目标页面
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 监听 click / input / scroll
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ├─ 包装 XHR / fetch
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 周期性上报到 /collect
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">后端
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ POST /collect → 追加写入 events.jsonl
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">本地 CLI
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └─ 解析 events.jsonl → 打印时间线 / 启动 rrweb-player
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>代码量很小，半天能跑通。&lt;/p>
&lt;h3 id="进阶版像素级回放">进阶版（像素级回放）
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 浏览器侧：rrweb 录制
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">record&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="nx">from&lt;/span> &lt;span class="s1">&amp;#39;rrweb&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">events&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">record&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">emit&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">events&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 周期性批量上报到后端
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="nx">setInterval&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">postEvents&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">events&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">splice&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">)),&lt;/span> &lt;span class="mi">1000&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// 回放侧
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">import&lt;/span> &lt;span class="nx">rrwebPlayer&lt;/span> &lt;span class="nx">from&lt;/span> &lt;span class="s1">&amp;#39;rrweb-player&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">new&lt;/span> &lt;span class="nx">rrwebPlayer&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">target&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">body&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">props&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">events&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">fetchedEvents&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="选型建议">选型建议
&lt;/h2>&lt;p>按&amp;quot;想做什么&amp;quot;分场景推荐：&lt;/p>
&lt;table>
&lt;thead>
&lt;tr>
&lt;th>需求&lt;/th>
&lt;th>推荐方案&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>自己排查自动化 bug&lt;/td>
&lt;td>&lt;strong>Playwright Trace&lt;/strong>（&lt;code>context.tracing.start()&lt;/code>），零成本&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>给自家产品做用户行为分析&lt;/td>
&lt;td>&lt;strong>rrweb + 自建后端&lt;/strong>，可控、私有化部署友好&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>想要现成的商业级方案&lt;/td>
&lt;td>&lt;strong>LogRocket / SessionStack&lt;/strong>&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>只想看热力图&lt;/td>
&lt;td>&lt;strong>Hotjar / Microsoft Clarity&lt;/strong>（免费）&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;h2 id="与现有-patchright-项目的结合点">与现有 patchright 项目的结合点
&lt;/h2>&lt;p>本仓库（kimi-patchright）本身在做浏览器自动化相关的 slide 调试。如果只是想在调试 slide 自动化流程时复盘浏览器里的真实操作 + 接口调用，&lt;strong>Playwright Trace + patchright 的 &lt;code>page.on('request')&lt;/code> / &lt;code>page.on('response')&lt;/code> 就够用了&lt;/strong>，不需要单独再搭一套录制系统。&lt;/p>
&lt;p>如果目标是 toC 的通用录制工具，那需要再想清楚定位：是要 rrweb 那种像素回放，还是只做事件流（轻量、可被搜索聚合）。&lt;/p>
&lt;h2 id="参考资料">参考资料
&lt;/h2>&lt;ul>
&lt;li>rrweb: &lt;a class="link" href="https://github.com/rrweb-io/rrweb" target="_blank" rel="noopener"
>https://github.com/rrweb-io/rrweb&lt;/a>&lt;/li>
&lt;li>SessionStack 原理: &lt;a class="link" href="https://www.sessionstack.com/" target="_blank" rel="noopener"
>https://www.sessionstack.com/&lt;/a>&lt;/li>
&lt;li>Playwright Trace: &lt;a class="link" href="https://playwright.dev/docs/trace-viewer" target="_blank" rel="noopener"
>https://playwright.dev/docs/trace-viewer&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>AI 生成前端的 E2E 实践：用 Playwright 做视觉回归和功能兜底</title><link>https://www.zata.cc/p/ai-generated-frontend-e2e-testing/</link><pubDate>Thu, 25 Jun 2026 14:00:00 +0800</pubDate><guid>https://www.zata.cc/p/ai-generated-frontend-e2e-testing/</guid><description>&lt;img src="https://www.zata.cc/p/ai-generated-frontend-e2e-testing/images/index/index.svg" alt="Featured image of post AI 生成前端的 E2E 实践：用 Playwright 做视觉回归和功能兜底" />&lt;blockquote>
&lt;p>这是「AI 时代的前端设计与实现」系列的一篇补充。前面我们聊了角色、设计系统、rules 模板和 Figma MCP；这一篇聚焦工程化落地里最常被低估的环节——&lt;strong>端到端测试&lt;/strong>。&lt;/p>
&lt;/blockquote>
&lt;h2 id="为什么-ai-生成的前端特别需要-e2e">为什么 AI 生成的前端特别需要 E2E
&lt;/h2>&lt;p>AI 写前端的速度很快，但它有两个天然短板：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>没有全局视角&lt;/strong>：改 A 页面时很容易把 B 页面的样式或交互带崩。&lt;/li>
&lt;li>&lt;strong>对边界状态不敏感&lt;/strong>：loading、error、empty、无权限这些状态经常被忽略。&lt;/li>
&lt;/ol>
&lt;p>更麻烦的是，AI 生成的代码往往&lt;strong>看起来没问题，一跑就报错&lt;/strong>。TypeScript 和 ESLint 能拦住类型和语法错误，但拦不住这些问题：&lt;/p>
&lt;ul>
&lt;li>按钮点了没反应&lt;/li>
&lt;li>表单提交后没跳转&lt;/li>
&lt;li>某个弹窗在特定分辨率下错位&lt;/li>
&lt;li>改完配色后整个页面气质变了&lt;/li>
&lt;/ul>
&lt;p>E2E 测试就是针对这些问题的最后一道防线。它不只是测功能，还能帮你做&lt;strong>视觉验收&lt;/strong>和&lt;strong>回归防护&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="e2e-要测什么">E2E 要测什么
&lt;/h2>&lt;p>不要试图把所有东西都用 E2E 测。抓住三类高价值场景：&lt;/p>
&lt;h3 id="1-核心用户流程happy-path">1. 核心用户流程（Happy Path）
&lt;/h3>&lt;p>用户最常用的路径必须稳定。例如电商站的「搜索 → 加购 → 结算 → 支付」。&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">test&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">expect&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@playwright/test&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;用户完成购买流程&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/products&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;product-card&amp;#34;]:first-child&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;add-to-cart&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;checkout-button&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[name=&amp;#34;email&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;user@example.com&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;submit-order&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;text=订单已创建&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toBeVisible&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="2-错误和边界状态">2. 错误和边界状态
&lt;/h3>&lt;p>AI 最容易漏的恰恰是这些：&lt;/p>
&lt;ul>
&lt;li>网络失败时的 fallback UI&lt;/li>
&lt;li>搜索无结果&lt;/li>
&lt;li>表单校验失败&lt;/li>
&lt;li>未登录访问需要权限的页面&lt;/li>
&lt;/ul>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;搜索无结果展示空状态&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/search&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[name=&amp;#34;q&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;xyznotfound123&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">press&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[name=&amp;#34;q&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;Enter&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;empty-state&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toBeVisible&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;text=没有找到相关结果&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toBeVisible&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="3-视觉回归">3. 视觉回归
&lt;/h3>&lt;p>这是 AI 前端最关键的能力。每次 AI 改完代码，自动截图和基准图对比，像素级变化都能发现。&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;首页视觉回归&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;homepage.png&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">maxDiffPixels&lt;/span>: &lt;span class="kt">100&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="工具链选择">工具链选择
&lt;/h2>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>工具&lt;/th>
&lt;th>适用场景&lt;/th>
&lt;th>推荐度&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>&lt;strong>Playwright&lt;/strong>&lt;/td>
&lt;td>功能测试、截图回归、多浏览器、移动端模拟、trace 调试&lt;/td>
&lt;td>⭐⭐⭐&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Cypress&lt;/td>
&lt;td>社区成熟，但多标签页、iframe 支持弱&lt;/td>
&lt;td>⭐⭐&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Selenium&lt;/td>
&lt;td>老项目维护，新项目不推荐&lt;/td>
&lt;td>⭐&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chromatic / Percy&lt;/td>
&lt;td>专业 UI 回归 SaaS，适合组件级视觉对比&lt;/td>
&lt;td>⭐⭐⭐&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>axe-core&lt;/td>
&lt;td>无障碍扫描&lt;/td>
&lt;td>⭐⭐⭐&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>建议：&lt;/strong> 新项目直接用 Playwright。它一个工具就能覆盖功能测试、视觉回归、响应式、多浏览器，失败时还能看 trace 回放。&lt;/p>
&lt;hr>
&lt;h2 id="playwright-实战配置">Playwright 实战配置
&lt;/h2>&lt;h3 id="安装">安装
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">npm init playwright@latest
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="基础配置">基础配置
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// playwright.config.ts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">defineConfig&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">devices&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@playwright/test&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">export&lt;/span> &lt;span class="k">default&lt;/span> &lt;span class="nx">defineConfig&lt;/span>&lt;span class="p">({&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">testDir&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;./e2e&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">fullyParallel&lt;/span>: &lt;span class="kt">true&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">forbidOnly&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="o">!!&lt;/span>&lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CI&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">retries&lt;/span>: &lt;span class="kt">process.env.CI&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">2&lt;/span> : &lt;span class="kt">0&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">workers&lt;/span>: &lt;span class="kt">process.env.CI&lt;/span> &lt;span class="o">?&lt;/span> &lt;span class="nx">1&lt;/span> : &lt;span class="kt">undefined&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reporter&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[[&lt;/span>&lt;span class="s1">&amp;#39;html&amp;#39;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;list&amp;#39;&lt;/span>&lt;span class="p">]],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">baseURL&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">trace&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;on-first-retry&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">screenshot&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;only-on-failure&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">video&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;retain-on-failure&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">projects&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;chromium&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">devices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;Desktop Chrome&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;firefox&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">devices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;Desktop Firefox&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;webkit&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">devices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;Desktop Safari&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;Mobile Chrome&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">devices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;Pixel 5&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">name&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;Mobile Safari&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">use&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="p">...&lt;/span>&lt;span class="nx">devices&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s1">&amp;#39;iPhone 12&amp;#39;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">],&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">webServer&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">command&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;npm run dev&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">url&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;http://localhost:3000&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">reuseExistingServer&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="o">!&lt;/span>&lt;span class="nx">process&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">env&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">CI&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>关键配置解读：&lt;/p>
&lt;ul>
&lt;li>&lt;code>trace: 'on-first-retry'&lt;/code>：失败时保留 trace，可以逐帧回放。&lt;/li>
&lt;li>&lt;code>screenshot: 'only-on-failure'&lt;/code>：失败自动截图。&lt;/li>
&lt;li>&lt;code>retries: 2&lt;/code>：CI 里重试 2 次，排除 flaky。&lt;/li>
&lt;li>&lt;code>webServer&lt;/code>：自动起本地服务。&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="视觉回归怎么做">视觉回归怎么做
&lt;/h2>&lt;h3 id="基础截图对比">基础截图对比
&lt;/h3>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;登录页视觉回归&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForSelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;login-form&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;login-page.png&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">maxDiffPixels&lt;/span>: &lt;span class="kt">100&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">threshold&lt;/span>: &lt;span class="kt">0.2&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="组件级视觉回归">组件级视觉回归
&lt;/h3>&lt;p>如果你想测某个组件在不同状态下的样子：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;按钮各状态截图&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/button-demos&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;hover-trigger&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;button-primary&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;button-hover.png&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;focus-trigger&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;button-primary&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;button-focus.png&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;loading-trigger&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">locator&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;button-primary&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;button-loading.png&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="让视觉回归更稳定">让视觉回归更稳定
&lt;/h3>&lt;p>视觉回归最怕 flaky。几个工程化技巧：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>固定字体加载&lt;/strong>：截图前确保字体已加载。
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">waitForFunction&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fonts&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">ready&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>&lt;strong>屏蔽动态内容&lt;/strong>：时间、随机数、动画元素用 CSS 隐藏或 mock。
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addStyleTag&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">content&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;[data-testid=&amp;#34;current-time&amp;#34;] { visibility: hidden !important; }&amp;#39;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;li>&lt;strong>统一 viewport 和缩放&lt;/strong>：在 &lt;code>playwright.config.ts&lt;/code> 里统一设备配置。&lt;/li>
&lt;li>&lt;strong>动画禁用&lt;/strong>：
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">addStyleTag&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">content&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s1">&amp;#39;*, *::before, *::after { animation-duration: 0s !important; transition-duration: 0s !important; }&amp;#39;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="响应式测试">响应式测试
&lt;/h2>&lt;p>AI 生成的页面常常在桌面端好看，移动端就崩。Playwright 可以很方便地多分辨率截图：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">describe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;响应式首页&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">use&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">viewport&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">width&lt;/span>: &lt;span class="kt">375&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>: &lt;span class="kt">667&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;移动端&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;homepage-mobile.png&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">describe&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;响应式首页桌面端&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">use&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">viewport&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">width&lt;/span>: &lt;span class="kt">1440&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>: &lt;span class="kt">900&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;桌面端&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toHaveScreenshot&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;homepage-desktop.png&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="无障碍测试">无障碍测试
&lt;/h2>&lt;p>AI 生成的代码容易忽略 &lt;code>alt&lt;/code>、&lt;code>aria-label&lt;/code>、对比度等。用 axe-core 跑一遍：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">test&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">expect&lt;/span> &lt;span class="p">}&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@playwright/test&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kr">import&lt;/span> &lt;span class="nx">AxeBuilder&lt;/span> &lt;span class="kr">from&lt;/span> &lt;span class="s1">&amp;#39;@axe-core/playwright&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;首页无严重无障碍问题&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="kr">async&lt;/span> &lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">results&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">AxeBuilder&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">page&lt;/span> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">withTags&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="s1">&amp;#39;wcag2a&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;wcag2aa&amp;#39;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">analyze&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">results&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">violations&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toEqual&lt;/span>&lt;span class="p">([]);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="让-ai-参与测试工作流">让 AI 参与测试工作流
&lt;/h2>&lt;p>E2E 不只是写测试，还可以让 AI 帮你生成和维护测试。&lt;/p>
&lt;h3 id="1-从用户故事生成测试">1. 从用户故事生成测试
&lt;/h3>&lt;p>把 PRD 或用户故事喂给 AI：&lt;/p>
&lt;blockquote>
&lt;p>用户搜索商品，选择第一个结果加入购物车，进入结算页填写邮箱，提交订单后看到成功提示。&lt;/p>
&lt;/blockquote>
&lt;p>AI 输出 Playwright 测试代码，你只需微调选择器。&lt;/p>
&lt;h3 id="2-从-bug-报告生成回归测试">2. 从 Bug 报告生成回归测试
&lt;/h3>&lt;p>遇到一个 bug 后，先让 AI 写一个能复现它的 E2E 测试。修完 bug 再跑这个测试，确保不会回归。&lt;/p>
&lt;h3 id="3-自动分析失败原因">3. 自动分析失败原因
&lt;/h3>&lt;p>Playwright 失败时会生成 trace。你可以把 trace 截图或错误日志丢给 AI，让它给出修复建议，比如：&lt;/p>
&lt;ul>
&lt;li>某个选择器不稳定，建议加 &lt;code>data-testid&lt;/code>&lt;/li>
&lt;li>页面还没加载完，建议加 &lt;code>waitForSelector&lt;/code>&lt;/li>
&lt;li>某个 API 响应慢，建议 mock&lt;/li>
&lt;/ul>
&lt;h3 id="4-ai-视觉验收闭环">4. AI 视觉验收闭环
&lt;/h3>&lt;p>结合 Chrome MCP / Playwright，可以建立这样的 workflow：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">AI 生成代码
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Playwright 跑 E2E + 截图
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">AI 对比截图 vs Figma 设计稿
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">AI 提出样式修复建议
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">AI 再次生成代码
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↓
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">重新跑 E2E
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这个闭环能大幅减少人工 Design QA 的工作量。&lt;/p>
&lt;hr>
&lt;h2 id="推荐的目录结构">推荐的目录结构
&lt;/h2>&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">e2e/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── fixtures/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── users.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── products.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── page-objects/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── LoginPage.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── ProductPage.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── CheckoutPage.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── specs/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── auth.spec.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── product.spec.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── checkout.spec.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── visual/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── homepage.visual.spec.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── login.visual.spec.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">├── utils/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ ├── test-helpers.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">│ └── mock-api.ts
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">└── snapshots/
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> └── baseline/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>Page Object 模式&lt;/strong>很重要。把选择器封装到 Page Object 里，AI 改了 DOM 结构时，只需要改一处：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-ts" data-lang="ts">&lt;span class="line">&lt;span class="cl">&lt;span class="c1">// e2e/page-objects/LoginPage.ts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="c1">&lt;/span>&lt;span class="kr">export&lt;/span> &lt;span class="kr">class&lt;/span> &lt;span class="nx">LoginPage&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">constructor&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="kr">private&lt;/span> &lt;span class="nx">page&lt;/span>: &lt;span class="kt">Page&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">async&lt;/span> &lt;span class="kr">goto&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="kr">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;/login&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">async&lt;/span> &lt;span class="nx">login&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">email&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">password&lt;/span>: &lt;span class="kt">string&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;email-input&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">email&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">fill&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;password-input&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">password&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="k">this&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;[data-testid=&amp;#34;login-button&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="cicd-集成">CI/CD 集成
&lt;/h2>&lt;p>在 GitHub Actions 里跑：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">E2E Tests&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">pull_request&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">branches&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="l">main]&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">&lt;/span>&lt;span class="nt">jobs&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">e2e&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">runs-on&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">ubuntu-latest&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">steps&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/checkout@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/setup-node@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">node-version&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="m">20&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">cache&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="s1">&amp;#39;npm&amp;#39;&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">npm ci&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">npx playwright install --with-deps&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">run&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">npx playwright test&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">Upload Playwright report&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">if&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">failure()&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">uses&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">actions/upload-artifact@v4&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">with&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">name&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="l">playwright-report&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">path&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w"> &lt;/span>&lt;span class="p">|&lt;/span>&lt;span class="sd">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> playwright-report/
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="sd"> test-results/&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="视觉回归基准图管理">视觉回归基准图管理
&lt;/h3>&lt;p>视觉回归最怕 baseline 不同步。建议：&lt;/p>
&lt;ul>
&lt;li>本地开发改 UI 时，跑 &lt;code>npx playwright test --update-snapshots&lt;/code> 更新自己负责的快照。&lt;/li>
&lt;li>PR 里如果改了 UI，必须在 PR 描述里说明更新了哪些截图。&lt;/li>
&lt;li>CI 里不允许自动更新快照，防止 silently 通过视觉回归。&lt;/li>
&lt;/ul>
&lt;hr>
&lt;h2 id="给-ai-的-rules-建议">给 AI 的 Rules 建议
&lt;/h2>&lt;p>把 E2E 要求写进 &lt;code>.cursorrules&lt;/code> 或 &lt;code>.cursor/rules/*.mdc&lt;/code>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-markdown" data-lang="markdown">&lt;span class="line">&lt;span class="cl">&lt;span class="gu">## E2E 规范
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="gu">&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 新增页面或核心交互后，必须在 &lt;span class="sb">`e2e/specs/`&lt;/span> 下补充对应测试
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 优先使用 Playwright 和 &lt;span class="sb">`data-testid`&lt;/span> 选择器，避免依赖 CSS 类名
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 每个核心流程测试必须覆盖：正常流程、空状态、错误状态
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 视觉改动必须更新 Playwright baseline 截图
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 提交前运行 &lt;span class="sb">`npm run test:e2e`&lt;/span>，确保通过
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">-&lt;/span> 不要写死等待时间，优先用 &lt;span class="sb">`waitForSelector`&lt;/span> / &lt;span class="sb">`toBeVisible`&lt;/span> 等断言
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="总结">总结
&lt;/h2>&lt;p>E2E 测试对 AI 生成的前端有三层价值：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>功能兜底&lt;/strong>：核心流程不崩溃、边界状态有处理。&lt;/li>
&lt;li>&lt;strong>视觉验收&lt;/strong>：每次改动自动截图对比，防止 UI 漂移。&lt;/li>
&lt;li>&lt;strong>回归保险&lt;/strong>：后续 AI 迭代时，不会把已有的功能改坏。&lt;/li>
&lt;/ol>
&lt;p>它和 design token、组件库、TypeScript、rules 组合起来，才能把「AI 生成前端」从 demo 变成可维护、可上线的产品。&lt;/p>
&lt;hr>
&lt;h2 id="参考">参考
&lt;/h2>&lt;ul>
&lt;li>&lt;a class="link" href="https://playwright.dev/" target="_blank" rel="noopener"
>Playwright 官方文档&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://www.npmjs.com/package/@axe-core/playwright" target="_blank" rel="noopener"
>axe-core for Playwright&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://www.chromatic.com/" target="_blank" rel="noopener"
>Chromatic 视觉回归&lt;/a>&lt;/li>
&lt;/ul></description></item><item><title>从 noVNC 到 Playwright 截图流：容器内 VNC 踩坑记</title><link>https://www.zata.cc/p/%E4%BB%8E-novnc-%E5%88%B0-playwright-%E6%88%AA%E5%9B%BE%E6%B5%81%E5%AE%B9%E5%99%A8%E5%86%85-vnc-%E8%B8%A9%E5%9D%91%E8%AE%B0/</link><pubDate>Tue, 09 Jun 2026 14:00:00 +0800</pubDate><guid>https://www.zata.cc/p/%E4%BB%8E-novnc-%E5%88%B0-playwright-%E6%88%AA%E5%9B%BE%E6%B5%81%E5%AE%B9%E5%99%A8%E5%86%85-vnc-%E8%B8%A9%E5%9D%91%E8%AE%B0/</guid><description>&lt;img src="https://www.zata.cc/p/%E4%BB%8E-novnc-%E5%88%B0-playwright-%E6%88%AA%E5%9B%BE%E6%B5%81%E5%AE%B9%E5%99%A8%E5%86%85-vnc-%E8%B8%A9%E5%9D%91%E8%AE%B0/images/index/index.png" alt="Featured image of post 从 noVNC 到 Playwright 截图流：容器内 VNC 踩坑记" />&lt;h2 id="背景">背景
&lt;/h2>&lt;p>项目是一个 FastAPI 后端 + Playwright 驱动的浏览器自动化服务，目标是让用户在网页里远程操作一个跑在 Docker 容器内的 Chrome，完成 &lt;code>kimi.com&lt;/code> 的一次性扫码/账号登录。登录态写入持久化 profile，后续 PPT 生成任务复用。&lt;/p>
&lt;p>远程桌面的需求看起来很简单：&lt;/p>
&lt;ul>
&lt;li>容器内：&lt;code>Xvfb&lt;/code>（虚拟显示）+ &lt;code>openbox&lt;/code>（窗口管理）+ &lt;code>x11vnc&lt;/code>（VNC 服务端）&lt;/li>
&lt;li>后端：FastAPI WebSocket 做字节中继，把前端的 RFB 流量转发到 &lt;code>127.0.0.1:5910&lt;/code>&lt;/li>
&lt;li>前端：&lt;code>@novnc/novnc&lt;/code> 的 &lt;code>RFB&lt;/code> 类连接 WebSocket，渲染桌面&lt;/li>
&lt;/ul>
&lt;p>架构上也很规矩：5910 不对外暴露，WebSocket 走 443/HTTPS，用管理员 token + 一次性 ticket 鉴权，没有旁路端口。&lt;/p>
&lt;p>PRD 写得整整齐齐，验收清单全部打勾，看起来万无一失。然后我们就掉进了坑里。&lt;/p>
&lt;hr>
&lt;h2 id="坑一mit-shm-0-byte-画面">坑一：MIT-SHM 0-byte 画面
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：noVNC 连接成功，但画面全黑，或者显示 0-byte 的帧。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：x11vnc 默认使用 X11 的 MIT-SHM（共享内存扩展）加速画面抓取。在 Docker 容器里，共享内存行为跟宿主机不一致，x11vnc 读不到像素数据。&lt;/p>
&lt;p>&lt;strong>修复&lt;/strong>（&lt;code>2fa2602&lt;/code>）：给 x11vnc 加参数绕过 MIT-SHM：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">x11vnc -noxshm -noxkb -noxrecord -noxfixes -noxdamage ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>或者通过环境变量透传：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">&lt;span class="nv">X11VNC_EXTRA_ARGS&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;-noxshm -ncache 0&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这个坑还算好填，加参数就能绕过。&lt;/p>
&lt;hr>
&lt;h2 id="坑二chrome-vizdisplaycompositor-导致黑屏">坑二：Chrome VizDisplayCompositor 导致黑屏
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：x11vnc 终于有画面了，但 Chrome 窗口区域永远是黑的。其他窗口（xterm、openbox）显示正常。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：Chrome 默认启用 Viz Display Compositor，把实际渲染放到独立的合成器进程里，用 GPU 或特殊缓冲区，导致普通的 X11 像素读取（&lt;code>XGetImage&lt;/code>、&lt;code>XShmGetImage&lt;/code>）抓不到 Chrome 内容。&lt;/p>
&lt;p>&lt;strong>修复&lt;/strong>（&lt;code>58cb8ed&lt;/code>）：启动 Chrome 时禁用 VizDisplayCompositor：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">google-chrome --disable-features&lt;span class="o">=&lt;/span>VizDisplayCompositor ...
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这个坑比较隐蔽，因为其他应用都能正常显示，唯独 Chrome 黑屏。排查了好一阵才定位到是 Chrome 的合成器机制。&lt;/p>
&lt;hr>
&lt;h2 id="坑三x11vnc-在-azure-vm-上的兼容性">坑三：x11vnc 在 Azure VM 上的兼容性
&lt;/h2>&lt;p>&lt;strong>症状&lt;/strong>：本地 Mac Docker Desktop 测试正常，推到 Azure VM（Ubuntu 24.04，内核 &lt;code>6.14.0-1017-azure&lt;/code>）后，x11vnc 要么崩溃，要么帧率极低，要么随机断开。&lt;/p>
&lt;p>&lt;strong>根因&lt;/strong>：x11vnc 对内核版本、X11 扩展、共享内存的依赖比较深，不同宿主机环境表现差异很大。&lt;/p>
&lt;p>这时候我们开始尝试替换 VNC 服务端。&lt;/p>
&lt;h3 id="尝试-1x0vncserver">尝试 1：x0vncserver
&lt;/h3>&lt;p>TigerVNC 的 &lt;code>x0vncserver&lt;/code> 更轻量，但 Ubuntu 24.04 的软件源里根本没有这个包（&lt;code>6765172&lt;/code>）。&lt;/p>
&lt;h3 id="尝试-2xtigervnc">尝试 2：Xtigervnc
&lt;/h3>&lt;p>切换到 &lt;code>Xtigervnc&lt;/code>（&lt;code>1d3222c&lt;/code>），但发现它的 X0 扩展支持不完整，抓取现有 X 会话的能力不如 x11vnc。&lt;/p>
&lt;h3 id="尝试-3x11vnc--ncache">尝试 3：x11vnc + ncache
&lt;/h3>&lt;p>最后又滚回 x11vnc，加上 &lt;code>-ncache 10&lt;/code> 做客户端缓存（&lt;code>8ba411d&lt;/code>），试图缓解帧率问题。&lt;/p>
&lt;p>这一系列反复说明一个问题：&lt;strong>在容器化 + 云服务器环境下跑传统 VNC 服务端，是一件非常脆弱的事&lt;/strong>。每个环境变量、每个内核版本、每个 X11 扩展都可能成为压垮骆驼的最后一根稻草。&lt;/p>
&lt;hr>
&lt;h2 id="最终方案彻底放弃-vnc改用-playwright-截图">最终方案：彻底放弃 VNC，改用 Playwright 截图
&lt;/h2>&lt;p>与其继续跟 x11vnc 搏斗，我们换了一个思路：&lt;/p>
&lt;p>&lt;strong>既然 Playwright 已经开着 Chrome，为什么不直接让 Playwright 截屏？&lt;/strong>&lt;/p>
&lt;p>新方案（&lt;code>40b9014&lt;/code>）：&lt;/p>
&lt;ol>
&lt;li>&lt;strong>后端&lt;/strong>：Playwright 的 &lt;code>Page.screenshot()&lt;/code> 每 250ms 截一张图&lt;/li>
&lt;li>&lt;strong>传输&lt;/strong>：自定义 &lt;code>WebSocketFrameBridge&lt;/code>，把 PNG 字节包上 length-prefixed JSON envelope，通过现有 WebSocket 推给前端&lt;/li>
&lt;li>&lt;strong>前端&lt;/strong>：不用 noVNC RFB 了，直接用原生 &lt;code>WebSocket&lt;/code> 收帧，把 PNG blob 塞进 &lt;code>&amp;lt;img&amp;gt;&lt;/code> 标签&lt;/li>
&lt;li>&lt;strong>输入&lt;/strong>：前端把鼠标/键盘事件序列化成 JSON，通过同一个 WebSocket 发回后端，后端用 &lt;code>page.mouse.click()&lt;/code>、&lt;code>page.keyboard.press()&lt;/code> 回放&lt;/li>
&lt;/ol>
&lt;p>Wire 格式：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-gdscript3" data-lang="gdscript3">&lt;span class="line">&lt;span class="cl">&lt;span class="mi">4&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">byte&lt;/span> &lt;span class="n">LE&lt;/span> &lt;span class="n">length&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">uint32&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">N&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="n">JSON&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="p">{&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;frame&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;seq&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">N&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;size&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="n">M&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s2">&amp;#34;format&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="s2">&amp;#34;jpeg&amp;#34;&lt;/span>&lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="mi">4&lt;/span>&lt;span class="o">-&lt;/span>&lt;span class="n">byte&lt;/span> &lt;span class="n">LE&lt;/span> &lt;span class="n">length&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="n">uint32&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">M&lt;/span> &lt;span class="n">bytes&lt;/span> &lt;span class="n">PNG&lt;/span> &lt;span class="n">payload&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;hr>
&lt;h2 id="新旧方案对比">新旧方案对比
&lt;/h2>&lt;table>
&lt;thead>
&lt;tr>
&lt;th>维度&lt;/th>
&lt;th>noVNC + x11vnc&lt;/th>
&lt;th>Playwright 截图流&lt;/th>
&lt;/tr>
&lt;/thead>
&lt;tbody>
&lt;tr>
&lt;td>依赖&lt;/td>
&lt;td>Xvfb + openbox + x11vnc + MIT-SHM workaround&lt;/td>
&lt;td>只有 Xvfb&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>Chrome 兼容性&lt;/td>
&lt;td>需要 &lt;code>--disable-features=VizDisplayCompositor&lt;/code>&lt;/td>
&lt;td>不需要特殊 flags&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>容器稳定性&lt;/td>
&lt;td>极易受宿主机内核/环境差异影响&lt;/td>
&lt;td>纯用户态，跟宿主机无关&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>带宽&lt;/td>
&lt;td>RFB 协议有增量压缩，带宽低&lt;/td>
&lt;td>每帧完整 PNG/JPEG，带宽高&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>延迟&lt;/td>
&lt;td>RFB 增量更新，延迟低&lt;/td>
&lt;td>250ms 轮询，延迟稍高&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>实现复杂度&lt;/td>
&lt;td>需要 RFB 中继、VNC 参数调优&lt;/td>
&lt;td>自定义帧协议，前后端约 200 行&lt;/td>
&lt;/tr>
&lt;tr>
&lt;td>可维护性&lt;/td>
&lt;td>x11vnc 是黑盒，出问题难排查&lt;/td>
&lt;td>Playwright 截图是白盒，可控&lt;/td>
&lt;/tr>
&lt;/tbody>
&lt;/table>
&lt;p>牺牲了一点带宽和延迟，换来的是&lt;strong>极高的稳定性和可维护性&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="教训">教训
&lt;/h2>&lt;ol>
&lt;li>
&lt;p>&lt;strong>VNC 协议是为局域网桌面共享设计的&lt;/strong>，强行塞进 Docker + 浏览器自动化的场景，属于削足适履。MIT-SHM、GPU 合成器、X11 扩展这些历史包袱在容器里会集中爆发。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>当你在同一个组件上打了 3 个以上补丁还搞不定时，应该考虑替换它&lt;/strong>。我们在 x11vnc 上修了 MIT-SHM、VizDisplayCompositor、ncache、环境参数透传……最后发现换掉它比修好它更容易。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>用你已经有的工具链解决问题&lt;/strong>。既然 Playwright 已经开着浏览器，截屏是它的一等公民 API，为什么不直接用？不要为了追求&amp;quot;标准方案&amp;quot;而引入一整套 VNC 技术栈。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>&amp;ldquo;网页远程桌面&amp;quot;不等于&amp;quot;必须实现 VNC/RDP 协议&amp;rdquo;&lt;/strong>。只要用户能看到画面、能发鼠标键盘，用什么协议不重要。自定义的 JSON+PNG 帧流完全够用。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;hr>
&lt;h2 id="代码速查">代码速查
&lt;/h2>&lt;p>如果你也在用 Playwright + Docker + 远程桌面，可以直接参考这个模式。&lt;/p>
&lt;p>&lt;strong>后端截屏&lt;/strong>（Python）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">next_frame&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="nb">bytes&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">screenshot&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nb">type&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;jpeg&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">quality&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="mi">70&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">full_page&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>前端渲染&lt;/strong>（TypeScript/React）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-typescript" data-lang="typescript">&lt;span class="line">&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">ws&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">WebSocket&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">url&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ws&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">binaryType&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s2">&amp;#34;arraybuffer&amp;#34;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="nx">ws&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">onmessage&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">blob&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nx">Blob&lt;/span>&lt;span class="p">([&lt;/span>&lt;span class="nx">event&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">data&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="kr">type&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="s2">&amp;#34;image/jpeg&amp;#34;&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="nx">setFrameUrl&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">URL&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">createObjectURL&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">blob&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>后端输入回放&lt;/strong>（Python）：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="k">async&lt;/span> &lt;span class="k">def&lt;/span> &lt;span class="nf">dispatch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="bp">self&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">:&lt;/span> &lt;span class="nb">dict&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="o">-&amp;gt;&lt;/span> &lt;span class="kc">None&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">if&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;click&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mouse&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">click&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;x&amp;#34;&lt;/span>&lt;span class="p">],&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;y&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">elif&lt;/span> &lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;type&amp;#34;&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="o">==&lt;/span> &lt;span class="s2">&amp;#34;keydown&amp;#34;&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="k">await&lt;/span> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">keyboard&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">press&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">event&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="s2">&amp;#34;key&amp;#34;&lt;/span>&lt;span class="p">])&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div></description></item><item><title>Playwright Chromium 在 Docker 内 SIGTRAP 启动崩溃排查实录</title><link>https://www.zata.cc/p/playwright-chromium-%E5%9C%A8-docker-%E5%86%85-sigtrap-%E5%90%AF%E5%8A%A8%E5%B4%A9%E6%BA%83%E6%8E%92%E6%9F%A5%E5%AE%9E%E5%BD%95/</link><pubDate>Mon, 08 Jun 2026 15:30:00 +0800</pubDate><guid>https://www.zata.cc/p/playwright-chromium-%E5%9C%A8-docker-%E5%86%85-sigtrap-%E5%90%AF%E5%8A%A8%E5%B4%A9%E6%BA%83%E6%8E%92%E6%9F%A5%E5%AE%9E%E5%BD%95/</guid><description>&lt;img src="https://www.zata.cc/p/playwright-chromium-%E5%9C%A8-docker-%E5%86%85-sigtrap-%E5%90%AF%E5%8A%A8%E5%B4%A9%E6%BA%83%E6%8E%92%E6%9F%A5%E5%AE%9E%E5%BD%95/images/index/index.png" alt="Featured image of post Playwright Chromium 在 Docker 内 SIGTRAP 启动崩溃排查实录" />&lt;h2 id="背景">背景
&lt;/h2>&lt;p>一个 FastAPI 后端 + Playwright（驱动 Chromium 做爬虫/自动化）的项目，通过 Dokploy 部署到一台 Azure VM（内核 &lt;code>6.14.0-1017-azure&lt;/code>，Ubuntu 24.04）。前端点登录按钮，后端通过 &lt;code>launch_persistent_context&lt;/code> 起 Chromium 让用户走 VNC 完成一次性登录。&lt;/p>
&lt;p>部署后第一次点登录就报：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">TargetClosedError: BrowserType.launch_persistent_context:
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">Target page, context or browser has been closed
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&amp;lt;launched&amp;gt; pid=191
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[pid=191][err] chrome_crashpad_handler: --database is required
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[pid=191][err] [191:191:...:ERROR:.../socket.cc:120] recvmsg: Connection reset by peer (104)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[pid=191] &amp;lt;process did exit: exitCode=null, signal=SIGTRAP&amp;gt;
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>chrome_crashpad_handler: --database is required&lt;/code> 看起来像主角，其实只是&lt;strong>遗书&lt;/strong>：父 chrome 进程已经 SIGTRAP，crashpad handler 被附带拉起来时没拿到参数，才吐了 usage。先理清这一点才不会被带偏。&lt;/p>
&lt;p>下面把六轮被自己打脸的错误假设逐一列出，再讲真正根因。&lt;strong>值得保留的不是答案，是思路&lt;/strong>。&lt;/p>
&lt;h2 id="假设-1旧-profile-跨版本残留">假设 1：旧 profile 跨版本残留
&lt;/h2>&lt;p>&lt;code>docker-compose&lt;/code> 把 &lt;code>kimi-ppt-browser-profile&lt;/code> 命名卷挂到 &lt;code>/app/.courier/browser-profiles&lt;/code>。第一反应：上次构建的 Chromium 写下的 profile 文件和这次不兼容，启动 assert。&lt;/p>
&lt;p>&lt;strong>结果&lt;/strong>：用户说&amp;quot;全新部署，没操作过任何东西&amp;quot;。卷是 fresh 的，假设不成立。&lt;/p>
&lt;h2 id="假设-2docker-默认-seccomp-拦-clone3">假设 2：Docker 默认 seccomp 拦 &lt;code>clone3&lt;/code>
&lt;/h2>&lt;p>Chromium 1223 (Playwright ≥ 1.49 bundled) 大量用 &lt;code>clone3&lt;/code>。老 Docker daemon（≤ 20.10.10）的默认 seccomp profile 直接 EPERM。&lt;/p>
&lt;p>修了一行 compose：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kimi-ppt-backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">security_opt&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">seccomp=unconfined&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>结果&lt;/strong>：重新部署 → 同样的 SIGTRAP。排除。&lt;/p>
&lt;h2 id="假设-3换系统-google-chrome-stable">假设 3：换系统 Google Chrome stable
&lt;/h2>&lt;p>既然 bundled chromium 1322 跑不起来，换 Google 官方 deb 装的 &lt;code>google-chrome-stable&lt;/code> 总行了吧？把 &lt;code>Dockerfile&lt;/code> 改成从 Google APT 装，&lt;code>KIMI_SLIDES_BROWSER_CHANNEL=chrome&lt;/code> 让 Playwright 通过 &lt;code>channel='chrome'&lt;/code> 调它。&lt;/p>
&lt;p>&lt;strong>结果&lt;/strong>：chrome 一样 SIGTRAP。说明不是 chromium binary 自身的 bug。&lt;/p>
&lt;h2 id="假设-4apparmor-拦-userns">假设 4：AppArmor 拦 userns
&lt;/h2>&lt;p>写了一个分层探针脚本进入容器实际查原因。关键输出：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">[1] docker inspect
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> AppArmorProfile: docker-default
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> SecurityOpt: [seccomp=unconfined]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[2] kernel sysctls
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> apparmor_restrict_unprivileged_userns = 1
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[4] strace of chrome stable
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> chrome 主 (pid 353) sendmsg(fd4, 40 bytes) → 给 crashpad handler 发 IPC 配置
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> crashpad handler (pid 355) execve(...)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> crashpad handler +++ exited with 1 +++
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> chrome 主 --- SIGTRAP si_code=SI_KERNEL ← IMMEDIATE_CRASH(int3)
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>apparmor_restrict_unprivileged_userns=1&lt;/code> 配合 &lt;code>docker-default&lt;/code> profile 会让 chrome 创建 user namespace 失败。给 compose 加：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-yaml" data-lang="yaml">&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">kimi-ppt-backend&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>&lt;span class="nt">security_opt&lt;/span>&lt;span class="p">:&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">seccomp=unconfined&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="w"> &lt;/span>- &lt;span class="l">apparmor=unconfined&lt;/span>&lt;span class="w">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>结果&lt;/strong>：AppArmor 是 unconfined 了，chrome 仍然 SIGTRAP。&lt;code>strace&lt;/code> 显示 crashpad handler 启动后没拿到 &lt;code>--database=&lt;/code> 参数就退（更详尽的 strace 揭示真正原因——见下），所以 userns 不是真正卡点。&lt;strong>这是被自己打脸的一轮&lt;/strong>：加 &lt;code>apparmor=unconfined&lt;/code> 之后我又跑了一次 strace，发现 crashpad handler 根本没走到 userns 那一步，它在解析 argv 阶段就 fail 了。&lt;/p>
&lt;blockquote>
&lt;p>留这一段不是为了丢脸——是因为这次&amp;quot;加了一行还是没好&amp;quot;教会我：&lt;strong>每加一行配置就重新跑一次 strace 看新输出&lt;/strong>，不能凭印象假设前一次 trace 仍然成立。&lt;/p>
&lt;/blockquote>
&lt;h2 id="真正根因home-目录不存在">真正根因：HOME 目录不存在
&lt;/h2>&lt;p>回头看 [4] 那段 strace：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">355 (handler) execve args = [...--initial-client-fd=5 --shared-client-connection]
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ↑ 注意：没有 --database=
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">355 write(stderr, &amp;#34;chrome_crashpad_handler: --datab...&amp;#34;)
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">355 +++ exited with 1 +++
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;strong>chrome 主进程传给 crashpad handler 的 argv 里没有 &lt;code>--database=&lt;/code>&lt;/strong>。为什么？因为 chrome 决定 &lt;code>--database=&lt;/code> 路径的算法是 &lt;code>~/.config/google-chrome/Crash Reports&lt;/code>，根据 &lt;code>$HOME&lt;/code> 推。&lt;/p>
&lt;p>继续探针：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">[2] HOME 探测
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> HOME=/home/appuser
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> ls -ld /home/appuser → No such file or directory
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">[3] 同样的 chrome 命令用 root 跑
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &amp;lt;html&amp;gt;&amp;lt;head&amp;gt;&amp;lt;/head&amp;gt;&amp;lt;body&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt; ← 正常！
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>Dockerfile&lt;/code> 里有一行：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="cl">&lt;span class="k">RUN&lt;/span> groupadd -r appgroup &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> useradd -r -g appgroup appuser&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>useradd -r&lt;/code> 创建系统用户，&lt;strong>默认不创 home 目录&lt;/strong>。&lt;code>appuser&lt;/code> 进了 &lt;code>/etc/passwd&lt;/code> 但 &lt;code>/home/appuser&lt;/code> 不存在。chrome 拿不到可写 &lt;code>$HOME&lt;/code> → 不传 &lt;code>--database=&lt;/code> 给 crashpad handler → handler exit 1 → chrome 主进程检测到 handler init 失败 → &lt;code>IMMEDIATE_CRASH()&lt;/code> 调 &lt;code>__builtin_debugtrap()&lt;/code> → SIGTRAP。&lt;/p>
&lt;p>root 能跑是因为 &lt;code>HOME=/root&lt;/code>，目录存在。&lt;/p>
&lt;p>&lt;strong>修复（一行 Dockerfile）&lt;/strong>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dockerfile" data-lang="dockerfile">&lt;span class="line">&lt;span class="cl">&lt;span class="k">RUN&lt;/span> groupadd -r appgroup &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> useradd -r -g appgroup -m -d /home/appuser appuser &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> mkdir -p /app/logs /app/downloads /app/.courier/browser-profiles &lt;span class="se">\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="se">&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> chown -R appuser:appgroup /app /home/appuser&lt;span class="err">
&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>useradd -m -d /home/appuser&lt;/code> 才会真创建 home 目录。&lt;/p>
&lt;p>&lt;strong>Dokploy Redeploy（重新 build 镜像，3-5 分钟，不是 9 秒的 hot restart）后，chrome 启动正常。&lt;/strong>&lt;/p>
&lt;h2 id="顺手修了一个-selector-bug">顺手修了一个 selector bug
&lt;/h2>&lt;p>chrome 起来后，前端点登录立刻弹&amp;quot;已检测到登录成功&amp;quot;——其实没登录。查代码发现 &lt;code>sites/kimi-slides.yaml:11&lt;/code> 配的是 &lt;code>logged_in_selector: &amp;quot;.chat-input-editor&amp;quot;&lt;/code>，而 kimi.com 的未登录落地页&lt;strong>就&lt;/strong>有 &lt;code>.chat-input-editor&lt;/code>，selector 立即命中，&lt;code>on_login_detected&lt;/code> 被错误 fire。&lt;/p>
&lt;p>从 VNC 里看登录后页面才出现的元素：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-html" data-lang="html">&lt;span class="line">&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;user-avatar-container&amp;#34;&lt;/span> &lt;span class="na">style&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;width: 28px; height: 28px;&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">img&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;user-avatar&amp;#34;&lt;/span> &lt;span class="na">src&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;...music_head.png&amp;#34;&lt;/span> &lt;span class="na">alt&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;登月者9246&amp;#34;&lt;/span> &lt;span class="p">/&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">span&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;user-name&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>登月者9246&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">span&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">div&lt;/span> &lt;span class="na">class&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;membership-upgrade&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>升级&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">div&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>membership-upgrade&lt;/code>（&amp;ldquo;升级&amp;quot;按钮）和 &lt;code>.user-avatar-container&lt;/code> 都是只在登录后才出现的——是更可靠的 post-auth marker。把 selector 换成 &lt;code>.user-avatar-container&lt;/code> 即可。&lt;/p>
&lt;h2 id="5-条复盘">5 条复盘
&lt;/h2>&lt;h3 id="1-chrome_crashpad_handler---database-is-required-是结果不是原因">1. &lt;code>chrome_crashpad_handler: --database is required&lt;/code> 是结果不是原因
&lt;/h3>&lt;p>每次看到这一行都要立刻去找父 chrome 进程的退出信号。&lt;code>SIGTRAP&lt;/code> / &lt;code>trap int3&lt;/code> 几乎一定是 Chromium 内部 &lt;code>IMMEDIATE_CRASH()&lt;/code>。crashpad 那段 usage 是它被附带拉起来时打出来的&amp;quot;遗言&amp;rdquo;，跟死因无关。&lt;/p>
&lt;h3 id="2-剥变量比改-compose-重要">2. 剥变量比改 compose 重要
&lt;/h3>&lt;p>报错涉及：卷 / 权限 / seccomp / X server / GPU / 内核 / CPU 指令 / 缺库 / 容器 runtime / app config / 用户身份。每一条都可能引发同一种症状。写脚本一次性把可控变量全部剥掉（手动跑 chromium、用 &lt;code>/tmp&lt;/code> 隔离卷、headless 隔离 X、&lt;code>--no-zygote --single-process&lt;/code> 隔离 sandbox），比改一行 compose 重新部署等结果效率高得多。&lt;/p>
&lt;p>特别有价值的一步是 &lt;strong>strace 死前最后那个 syscall&lt;/strong>——能直接定位到 &lt;code>clone3&lt;/code> / &lt;code>seccomp&lt;/code> / &lt;code>userfaultfd&lt;/code> / &lt;code>prctl&lt;/code> 哪一类失败。这一步把&amp;quot;猜容器层问题&amp;quot;变成&amp;quot;看 kernel 在哪一步拒绝&amp;quot;。&lt;/p>
&lt;h3 id="3---no-sandbox-不等于-sandbox-完全关掉">3. &lt;code>--no-sandbox&lt;/code> 不等于 sandbox 完全关掉
&lt;/h3>&lt;p>Chromium 至少有三层 sandbox：setuid sandbox、user-namespace sandbox、seccomp-bpf filter。&lt;code>--no-sandbox&lt;/code> 主要关掉前两层，仍可能在 seccomp 初始化阶段崩。要全关需要：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">--no-sandbox --disable-setuid-sandbox --disable-namespace-sandbox --disable-seccomp-filter-sandbox
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>但这只用于诊断——生产即便有问题也不要靠这个绕。&lt;/p>
&lt;h3 id="4-bundled-chromium-不是万能">4. bundled chromium 不是万能
&lt;/h3>&lt;p>Playwright 自带的 Chromium 是 Playwright 团队 patched 过的版本，覆盖矩阵窄于 Google 官方 Chrome stable。Azure / 较新 Hyper-V 内核 / 某些 ARM 宿主&lt;strong>三者叠加&lt;/strong>就可能让 bundled chromium 启动崩、而系统 Chrome stable 正常——&lt;strong>反过来&lt;/strong>也成立。生产容器优先用 &lt;code>channel='chrome'&lt;/code> 跑系统 Chrome 更保守。&lt;/p>
&lt;p>但！这次我们后来发现&amp;quot;换 Chrome stable&amp;quot;本身&lt;strong>也不是修复&lt;/strong>——根因（HOME 缺失）对两个 binary 一视同仁。换 binary 治不了真正的病，只是让症状换了个皮。&lt;/p>
&lt;h3 id="5-dockerfile-一行--m-缺失藏得最深">5. Dockerfile 一行 &lt;code>-m&lt;/code> 缺失，藏得最深
&lt;/h3>&lt;p>&lt;code>useradd -r&lt;/code> 不创 home——这是发行版默认行为，dev 阶段本地跑的时候从来不会触发（Mac 跑 docker 不走 rootfs，mount 行为不一样），CI 测试也跑过（其实没跑过——&lt;code>just test&lt;/code> 不覆盖 docker build）。这种 bug 只能靠&amp;quot;上 prod 看现象&amp;quot;暴露。&lt;/p>
&lt;p>防御：&lt;/p>
&lt;ul>
&lt;li>Dockerfile 优先用 &lt;code>adduser&lt;/code>（Debian 系）而不是 &lt;code>useradd&lt;/code>，&lt;code>adduser&lt;/code> 默认创 home 且交互友好&lt;/li>
&lt;li>或者显式 &lt;code>-m -d /home/xxx&lt;/code> 让意图不依赖隐式默认&lt;/li>
&lt;li>在 CI 里至少跑一次 &lt;code>docker build &amp;amp;&amp;amp; docker run&lt;/code> 验证 chrome 类应用能起，单纯跑 pytest 不够&lt;/li>
&lt;/ul>
&lt;h2 id="完整最终改动">完整最终改动
&lt;/h2>&lt;ul>
&lt;li>&lt;code>src/backend/Dockerfile&lt;/code>：&lt;code>useradd&lt;/code> 加 &lt;code>-m -d /home/appuser&lt;/code>；&lt;code>chown&lt;/code> 补 &lt;code>/home/appuser&lt;/code>&lt;/li>
&lt;li>&lt;code>docker-compose.dokploy.yml&lt;/code>：&lt;code>security_opt: [seccomp=unconfined, apparmor=unconfined]&lt;/code>（虽然对 chromium 启动不是必须的，但保险）&lt;/li>
&lt;li>&lt;code>sites/kimi-slides.yaml&lt;/code>：&lt;code>login_detection_selector&lt;/code> 从 &lt;code>.chat-input-editor&lt;/code> 换成 &lt;code>.user-avatar-container&lt;/code>&lt;/li>
&lt;/ul>
&lt;h2 id="附录诊断脚本模式">附录：诊断脚本模式
&lt;/h2>&lt;p>发现 &lt;code>strace&lt;/code> 末尾 &amp;ldquo;死前最后几个 syscall&amp;rdquo; 价值最高，标准化成模板：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-bash" data-lang="bash">&lt;span class="line">&lt;span class="cl">docker &lt;span class="nb">exec&lt;/span> &lt;span class="s2">&amp;#34;&lt;/span>&lt;span class="nv">$CONTAINER&lt;/span>&lt;span class="s2">&amp;#34;&lt;/span> sh -c &lt;span class="s1">&amp;#39;
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> CHROME=/opt/google/chrome/chrome
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> rm -rf /tmp/cp /tmp/strace.log
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> timeout 5 strace -f -y \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> -e trace=execve,clone,clone3,prctl,seccomp,unshare,ptrace,personality,\
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">membarrier,userfaultfd,pidfd_open \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> -o /tmp/strace.log \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> &amp;#34;$CHROME&amp;#34; --headless=new --no-sandbox --disable-dev-shm-usage --disable-gpu \
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> --user-data-dir=/tmp/cp about:blank &amp;gt;/dev/null 2&amp;gt;&amp;amp;1
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> HANDLER_PID=$(grep &amp;#34;execve.*chrome_crashpad_handler&amp;#34; /tmp/strace.log | head -1 | awk &amp;#34;{print \$1}&amp;#34;)
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1"> grep &amp;#34;^$HANDLER_PID &amp;#34; /tmp/strace.log | tail -40
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="s1">&amp;#39;&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>-y&lt;/code> 会显示 fd 对应的资源；&lt;code>-f&lt;/code> 跟踪子进程；trace 列表只列我们关心的 syscall 防止输出爆炸。&lt;/p>
&lt;p>任何 chromium 启动崩的现场，这套脚本是首选入口。&lt;/p></description></item><item><title>Playwright 使用实践与本地浏览器 Profile 避坑</title><link>https://www.zata.cc/p/playwright-%E4%BD%BF%E7%94%A8%E5%AE%9E%E8%B7%B5%E4%B8%8E%E6%9C%AC%E5%9C%B0%E6%B5%8F%E8%A7%88%E5%99%A8-profile-%E9%81%BF%E5%9D%91/</link><pubDate>Sun, 31 Aug 2025 08:13:49 +0800</pubDate><guid>https://www.zata.cc/p/playwright-%E4%BD%BF%E7%94%A8%E5%AE%9E%E8%B7%B5%E4%B8%8E%E6%9C%AC%E5%9C%B0%E6%B5%8F%E8%A7%88%E5%99%A8-profile-%E9%81%BF%E5%9D%91/</guid><description>&lt;img src="https://www.zata.cc/p/playwright-%E4%BD%BF%E7%94%A8%E5%AE%9E%E8%B7%B5%E4%B8%8E%E6%9C%AC%E5%9C%B0%E6%B5%8F%E8%A7%88%E5%99%A8-profile-%E9%81%BF%E5%9D%91/images/index/index.png" alt="Featured image of post Playwright 使用实践与本地浏览器 Profile 避坑" />&lt;h2 id="一个代价很大的坑">一个代价很大的坑
&lt;/h2>&lt;p>我之前写 Playwright 程序时，希望它直接使用电脑上安装的浏览器和已有登录状态，于是让它复用了本地 Chrome 的用户目录。结果自动化运行后，日常浏览器里的历史记录全部消失了。&lt;/p>
&lt;p>这个经历里最容易混淆的是两件事：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>使用本机安装的 Chrome 程序&lt;/strong>：通常没问题。&lt;/li>
&lt;li>&lt;strong>使用日常 Chrome 的用户数据目录（Profile）&lt;/strong>：风险很高，不应该这样做。&lt;/li>
&lt;/ul>
&lt;p>Playwright 默认创建隔离的 &lt;code>BrowserContext&lt;/code>，其行为类似一个全新的无痕环境，不会读取日常 Chrome 的历史记录、Cookie 或扩展。只有在使用 &lt;code>launch_persistent_context()&lt;/code> 并把 &lt;code>user_data_dir&lt;/code> 指向真实 Chrome 用户目录时，自动化程序才会直接读写日常浏览数据。&lt;/p>
&lt;h2 id="为什么真实-profile-可能出问题">为什么真实 Profile 可能出问题
&lt;/h2>&lt;p>Chrome 的用户目录中不只是 Cookie，还包含：&lt;/p>
&lt;ul>
&lt;li>&lt;code>History&lt;/code>：浏览历史数据库&lt;/li>
&lt;li>&lt;code>Cookies&lt;/code>：Cookie 数据库&lt;/li>
&lt;li>&lt;code>Login Data&lt;/code>：保存的登录信息&lt;/li>
&lt;li>&lt;code>Bookmarks&lt;/code>：书签&lt;/li>
&lt;li>&lt;code>Preferences&lt;/code>：浏览器配置&lt;/li>
&lt;li>扩展、缓存和站点本地存储&lt;/li>
&lt;/ul>
&lt;p>这些文件中有不少是 SQLite 数据库。普通 Chrome 和 Playwright 同时访问同一个 Profile，可能发生文件锁冲突、数据库损坏或配置覆盖。如果脚本还带有清理、复制、初始化目录的逻辑，就可能直接覆盖原数据。&lt;/p>
&lt;p>另外，Chrome 现在也限制自动化工具访问默认用户目录。Playwright 官方明确建议：不要把日常 Chrome 的主 &lt;code>User Data&lt;/code> 目录传给自动化程序，而应创建独立目录作为自动化 Profile。&lt;/p>
&lt;h2 id="错误示例直接复用日常-chrome-profile">错误示例：直接复用日常 Chrome Profile
&lt;/h2>&lt;p>macOS 上，Chrome 的用户数据通常位于：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">~/Library/Application Support/Google/Chrome
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不要这样写：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pathlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">chrome_user_data_dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">Path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">home&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="s2">&amp;#34;Library/Application Support/Google/Chrome&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch_persistent_context&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_data_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">chrome_user_data_dir&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">channel&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;chrome&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;https://example.com&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这里的危险点不是 &lt;code>channel=&amp;quot;chrome&amp;quot;&lt;/code>，而是 &lt;code>user_data_dir&lt;/code> 指向了日常使用的 Chrome 数据目录。&lt;/p>
&lt;p>还要注意，&lt;code>chrome://version&lt;/code> 中显示的“个人资料路径”可能是：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">.../Google/Chrome/Default
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>而 Chromium 启动参数所需的 &lt;code>user_data_dir&lt;/code> 是它的父目录：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">.../Google/Chrome
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>不要因为两者层级不同，就尝试把 &lt;code>Default&lt;/code> 或它的父目录逐个传给脚本测试；它们都属于真实浏览数据。&lt;/p>
&lt;h2 id="正确方案一使用系统-chrome但创建专用-profile">正确方案一：使用系统 Chrome，但创建专用 Profile
&lt;/h2>&lt;p>如果需要有头浏览器、持久化 Cookie 或长期登录状态，可以为自动化单独创建目录：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pathlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">automation_profile_dir&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">cwd&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="o">/&lt;/span> &lt;span class="s2">&amp;#34;.playwright-profile&amp;#34;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">automation_profile_dir&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mkdir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parents&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exist_ok&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch_persistent_context&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">user_data_dir&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">automation_profile_dir&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">channel&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;chrome&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pages&lt;/span>&lt;span class="p">[&lt;/span>&lt;span class="mi">0&lt;/span>&lt;span class="p">]&lt;/span> &lt;span class="k">if&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">pages&lt;/span> &lt;span class="k">else&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;https://example.com&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>这段代码中：&lt;/p>
&lt;ul>
&lt;li>&lt;code>channel=&amp;quot;chrome&amp;quot;&lt;/code> 使用本机安装的 Google Chrome。&lt;/li>
&lt;li>&lt;code>.playwright-profile&lt;/code> 只供自动化程序使用。&lt;/li>
&lt;li>关闭后 Cookie、本地存储等仍会保留，下一次运行可以继续使用。&lt;/li>
&lt;li>日常 Chrome 和自动化 Chrome 的数据完全分离。&lt;/li>
&lt;/ul>
&lt;p>应把专用 Profile 加入 &lt;code>.gitignore&lt;/code>，避免将 Cookie 或凭据提交到仓库：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">.playwright-profile/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="正确方案二只保存登录状态">正确方案二：只保存登录状态
&lt;/h2>&lt;p>如果只是运行测试，通常不需要保存整个浏览器 Profile。更轻量的做法是登录一次后导出 &lt;code>storage_state&lt;/code>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">pathlib&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">Path&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth_state_path&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">Path&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;playwright/.auth/user.json&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="n">auth_state_path&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">parent&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">mkdir&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">parents&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">exist_ok&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">True&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_context&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;https://example.com/login&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="c1"># 在这里完成登录。&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">storage_state&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">path&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="n">auth_state_path&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>后续运行时加载它：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_context&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">storage_state&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;playwright/.auth/user.json&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;https://example.com&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>storage_state&lt;/code> 主要保存 Cookie、localStorage 和 IndexedDB，比复制整个 Profile 更适合自动化测试。但这个文件仍可能包含有效登录凭据，也必须加入 &lt;code>.gitignore&lt;/code>：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-fallback" data-lang="fallback">&lt;span class="line">&lt;span class="cl">playwright/.auth/
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h2 id="普通隔离模式">普通隔离模式
&lt;/h2>&lt;p>不需要复用登录状态时，直接使用 Playwright 默认的隔离模式最安全：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-python" data-lang="python">&lt;span class="line">&lt;span class="cl">&lt;span class="kn">from&lt;/span> &lt;span class="nn">playwright.sync_api&lt;/span> &lt;span class="kn">import&lt;/span> &lt;span class="n">sync_playwright&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">&lt;span class="k">with&lt;/span> &lt;span class="n">sync_playwright&lt;/span>&lt;span class="p">()&lt;/span> &lt;span class="k">as&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="p">:&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">playwright&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">chromium&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">launch&lt;/span>&lt;span class="p">(&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">channel&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s2">&amp;#34;chrome&amp;#34;&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">headless&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="kc">False&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">context&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_context&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">context&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">new_page&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">page&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">goto&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s2">&amp;#34;https://example.com&amp;#34;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl"> &lt;span class="n">browser&lt;/span>&lt;span class="o">.&lt;/span>&lt;span class="n">close&lt;/span>&lt;span class="p">()&lt;/span>
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每个新建的 &lt;code>BrowserContext&lt;/code> 都是独立的干净环境。关闭 Context 后其中的临时 Cookie 和本地存储会被丢弃，但不会触碰日常 Chrome Profile。&lt;/p>
&lt;h2 id="安全检查清单">安全检查清单
&lt;/h2>&lt;p>在运行任何复用浏览器状态的 Playwright 程序前，至少检查以下事项：&lt;/p>
&lt;ol>
&lt;li>&lt;code>user_data_dir&lt;/code> 是否指向专用目录，而不是 Chrome、Edge 或 Chromium 的日常用户目录。&lt;/li>
&lt;li>普通浏览器和 Playwright 是否会同时打开同一个 Profile。&lt;/li>
&lt;li>cleanup 逻辑是否可能对未校验的路径执行 &lt;code>rmtree()&lt;/code>、&lt;code>rm -rf&lt;/code> 或覆盖复制。&lt;/li>
&lt;li>是否可以用 &lt;code>storage_state&lt;/code> 替代整个持久化 Profile。&lt;/li>
&lt;li>自动化 Profile 和认证状态文件是否已加入 &lt;code>.gitignore&lt;/code>。&lt;/li>
&lt;li>重要浏览器数据是否有同步或系统备份。&lt;/li>
&lt;/ol>
&lt;p>如果程序必须清理自动化 Profile，应使用固定的专用路径，并在删除前验证目录名称。不要对来自环境变量或外部输入、尚未解析和校验的路径直接执行递归删除。&lt;/p>
&lt;h2 id="历史记录消失后的排查">历史记录消失后的排查
&lt;/h2>&lt;p>发现历史记录不见后，先不要继续频繁启动 Chrome：&lt;/p>
&lt;ol>
&lt;li>完全退出普通 Chrome 和 Playwright。&lt;/li>
&lt;li>备份整个 Chrome 用户目录。&lt;/li>
&lt;li>打开 &lt;code>chrome://version&lt;/code>，确认普通 Chrome 当前使用的是 &lt;code>Default&lt;/code>、&lt;code>Profile 1&lt;/code> 还是其他 Profile。&lt;/li>
&lt;li>检查各 Profile 下是否仍有 &lt;code>History&lt;/code> 文件。&lt;/li>
&lt;li>检查 Chrome Sync、Time Machine 或其他系统备份。&lt;/li>
&lt;/ol>
&lt;p>macOS 上常见的历史数据库位置包括：&lt;/p>
&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="cl">~/Library/Application Support/Google/Chrome/Default/History
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="cl">~/Library/Application Support/Google/Chrome/Profile 1/History
&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>&lt;code>History&lt;/code> 是 SQLite 数据库。如果只是切换到了空 Profile，原数据可能仍在其他目录；如果数据库已被覆盖或重新创建，则需要从同步或备份中恢复。&lt;/p>
&lt;h2 id="总结">总结
&lt;/h2>&lt;p>使用 Playwright 时要区分“浏览器程序”和“浏览器数据”：&lt;/p>
&lt;ul>
&lt;li>想使用本地 Chrome：设置 &lt;code>channel=&amp;quot;chrome&amp;quot;&lt;/code>。&lt;/li>
&lt;li>想保存自动化登录状态：使用专用 &lt;code>user_data_dir&lt;/code>。&lt;/li>
&lt;li>只想复用测试登录态：优先使用 &lt;code>storage_state&lt;/code>。&lt;/li>
&lt;li>永远不要让自动化程序直接操作日常 Chrome Profile。&lt;/li>
&lt;/ul>
&lt;h2 id="参考资料">参考资料
&lt;/h2>&lt;ul>
&lt;li>&lt;a class="link" href="https://playwright.dev/python/docs/api/class-browsertype#browser-type-launch-persistent-context" target="_blank" rel="noopener"
>Playwright：BrowserType.launch_persistent_context&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://playwright.dev/python/docs/auth" target="_blank" rel="noopener"
>Playwright：Authentication&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://playwright.dev/python/docs/browser-contexts" target="_blank" rel="noopener"
>Playwright：Browser contexts&lt;/a>&lt;/li>
&lt;li>&lt;a class="link" href="https://cuiqingcai.com/36045.html" target="_blank" rel="noopener"
>Playwright Python 使用教程&lt;/a>&lt;/li>
&lt;/ul></description></item></channel></rss>