<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Playwright on Tarragon</title><link>https://tarrragon.github.io/blog/tags/playwright/</link><description>Recent content in Playwright on Tarragon</description><generator>Hugo -- gohugo.io</generator><language>zh-TW</language><copyright>Tarragon (CC BY 4.0)</copyright><lastBuildDate>Fri, 19 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://tarrragon.github.io/blog/tags/playwright/index.xml" rel="self" type="application/rss+xml"/><item><title>Playwright 瀏覽器驗證流程</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/</guid><description>&lt;p>Playwright 是瀏覽器自動化工具，在真實瀏覽器中執行 UI 操作並驗證結果。和 Flutter 的 widget test 不同，Playwright 操作的是瀏覽器中的 DOM 元素，驗證的是使用者在瀏覽器中實際看到的畫面。&lt;/p>
&lt;h2 id="playwright-和-widget-test-的互補">Playwright 和 widget test 的互補&lt;/h2>
&lt;p>Widget test 在 Flutter test framework 中執行，不需要瀏覽器，驗證的是 widget tree 的結構和狀態。Playwright 在真實瀏覽器中執行，驗證的是渲染後的 DOM 和視覺呈現。&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>維度&lt;/th>
 &lt;th>Widget test&lt;/th>
 &lt;th>Playwright&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>執行環境&lt;/td>
 &lt;td>Flutter test framework&lt;/td>
 &lt;td>真實瀏覽器（Chromium 等）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>驗證對象&lt;/td>
 &lt;td>Widget tree 結構&lt;/td>
 &lt;td>DOM 元素和視覺呈現&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;tr>
 &lt;td>適用場景&lt;/td>
 &lt;td>邏輯驗證、狀態覆蓋&lt;/td>
 &lt;td>視覺驗證、跨瀏覽器相容&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CSS 驗證&lt;/td>
 &lt;td>無法驗證 CSS 渲染&lt;/td>
 &lt;td>可以驗證 CSS 效果&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩者的分工：widget test 驗證「邏輯上正確」（該有的元素存在、該觸發的事件發生），Playwright 驗證「視覺上正確」（元素在正確的位置、顏色和大小符合設計）。&lt;/p>
&lt;h2 id="playwright-test-的基本結構">Playwright test 的基本結構&lt;/h2>





&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="ln"> 1&lt;/span>&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="ln"> 2&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal screen shows connection status&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="ln"> 4&lt;/span>&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;http://localhost:8080&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> 
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 點擊連線按鈕
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &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;text=Connect Terminal&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> 
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 等待畫面轉換
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &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;terminal-screen&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> 
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 驗證連線狀態顯示
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">const&lt;/span> &lt;span class="nx">status&lt;/span> &lt;span class="o">=&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;connection-status&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">14&lt;/span>&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">status&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="ln">15&lt;/span>&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>Playwright test 中的斷言放在三個位置，各自驗證不同的東西：&lt;/p>
&lt;p>&lt;strong>假設斷言（test 開頭）&lt;/strong>：驗證 test 的前置條件。頁面載入成功、初始狀態正確。如果假設斷言失敗，test 的後續結果不可信。&lt;/p>
&lt;p>&lt;strong>行為斷言（操作之後）&lt;/strong>：驗證 UI 操作的即時效果。點擊按鈕後 dialog 出現、表單提交後顯示成功訊息。&lt;/p>
&lt;p>&lt;strong>互動斷言（流程結束）&lt;/strong>：驗證完整操作流程的最終狀態。多步驟操作完成後畫面回到預期狀態。&lt;/p>
&lt;h2 id="selector-策略">Selector 策略&lt;/h2>
&lt;p>Playwright 用 selector 定位 DOM 元素。Selector 的穩定性決定了 test 的維護成本。&lt;/p>
&lt;h3 id="推薦data-testid">推薦：data-testid&lt;/h3>
&lt;p>在 HTML 元素上加 &lt;code>data-testid&lt;/code> 屬性，Playwright 用 &lt;code>[data-testid=&amp;quot;xxx&amp;quot;]&lt;/code> 定位。&lt;code>data-testid&lt;/code> 不受 CSS class 改名、文字內容變更、DOM 結構調整影響。&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="p">&amp;lt;&lt;/span>&lt;span class="nt">button&lt;/span> &lt;span class="na">data-testid&lt;/span>&lt;span class="o">=&lt;/span>&lt;span class="s">&amp;#34;connect-button&amp;#34;&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>Connect Terminal&lt;span class="p">&amp;lt;/&lt;/span>&lt;span class="nt">button&lt;/span>&lt;span class="p">&amp;gt;&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="可接受文字內容">可接受：文字內容&lt;/h3>
&lt;p>用 &lt;code>text=Connect Terminal&lt;/code> 定位。在按鈕文字穩定的場景下可用，但多語系支援或文案調整時會斷。&lt;/p>
&lt;h3 id="避免css-selector">避免：CSS selector&lt;/h3>
&lt;p>用 &lt;code>.btn-primary&lt;/code> 或 &lt;code>#main-content &amp;gt; div:nth-child(2)&lt;/code> 定位。CSS class 和 DOM 結構的改動頻率高，test 頻繁因無關變更而失敗。&lt;/p>
&lt;h2 id="和開發伺服器的整合">和開發伺服器的整合&lt;/h2>
&lt;p>Playwright test 需要一個正在運行的 web 應用。整合方式：&lt;/p>
&lt;p>&lt;strong>手動啟動&lt;/strong>：開發者先啟動 dev server，再跑 Playwright test。適合本地開發。&lt;/p>
&lt;p>&lt;strong>自動啟動&lt;/strong>：Playwright 設定檔中指定 &lt;code>webServer&lt;/code> 配置，Playwright 自動啟動 dev server，test 結束後自動停止。適合 CI。&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// playwright.config.ts
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span>&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="ln">3&lt;/span>&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="ln">4&lt;/span>&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="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">port&lt;/span>: &lt;span class="kt">8080&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&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="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">},&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&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;ul>
&lt;li>視覺比對 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對&lt;/a>&lt;/li>
&lt;li>狀態覆蓋策略 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略&lt;/a>&lt;/li>
&lt;li>導航路徑 test → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>Playwright 是瀏覽器自動化工具，在真實瀏覽器中執行 UI 操作並驗證結果。和 Flutter 的 widget test 不同，Playwright 操作的是瀏覽器中的 DOM 元素，驗證的是使用者在瀏覽器中實際看到的畫面。</p>
<h2 id="playwright-和-widget-test-的互補">Playwright 和 widget test 的互補</h2>
<p>Widget test 在 Flutter test framework 中執行，不需要瀏覽器，驗證的是 widget tree 的結構和狀態。Playwright 在真實瀏覽器中執行，驗證的是渲染後的 DOM 和視覺呈現。</p>
<table>
  <thead>
      <tr>
          <th>維度</th>
          <th>Widget test</th>
          <th>Playwright</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>執行環境</td>
          <td>Flutter test framework</td>
          <td>真實瀏覽器（Chromium 等）</td>
      </tr>
      <tr>
          <td>驗證對象</td>
          <td>Widget tree 結構</td>
          <td>DOM 元素和視覺呈現</td>
      </tr>
      <tr>
          <td>速度</td>
          <td>毫秒級</td>
          <td>秒級</td>
      </tr>
      <tr>
          <td>穩定性</td>
          <td>高（無瀏覽器差異）</td>
          <td>中（瀏覽器行為差異）</td>
      </tr>
      <tr>
          <td>適用場景</td>
          <td>邏輯驗證、狀態覆蓋</td>
          <td>視覺驗證、跨瀏覽器相容</td>
      </tr>
      <tr>
          <td>CSS 驗證</td>
          <td>無法驗證 CSS 渲染</td>
          <td>可以驗證 CSS 效果</td>
      </tr>
  </tbody>
</table>
<p>兩者的分工：widget test 驗證「邏輯上正確」（該有的元素存在、該觸發的事件發生），Playwright 驗證「視覺上正確」（元素在正確的位置、顏色和大小符合設計）。</p>
<h2 id="playwright-test-的基本結構">Playwright test 的基本結構</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="kr">from</span> <span class="s1">&#39;@playwright/test&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;terminal screen shows connection status&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;http://localhost:8080&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="c1">// 點擊連線按鈕
</span></span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"></span>  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;text=Connect Terminal&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="c1">// 等待畫面轉換
</span></span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1"></span>  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;terminal-screen&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl">  
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="c1">// 驗證連線狀態顯示
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>  <span class="kr">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;connection-status&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">  <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">status</span><span class="p">).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h3 id="三個位置的斷言">三個位置的斷言</h3>
<p>Playwright test 中的斷言放在三個位置，各自驗證不同的東西：</p>
<p><strong>假設斷言（test 開頭）</strong>：驗證 test 的前置條件。頁面載入成功、初始狀態正確。如果假設斷言失敗，test 的後續結果不可信。</p>
<p><strong>行為斷言（操作之後）</strong>：驗證 UI 操作的即時效果。點擊按鈕後 dialog 出現、表單提交後顯示成功訊息。</p>
<p><strong>互動斷言（流程結束）</strong>：驗證完整操作流程的最終狀態。多步驟操作完成後畫面回到預期狀態。</p>
<h2 id="selector-策略">Selector 策略</h2>
<p>Playwright 用 selector 定位 DOM 元素。Selector 的穩定性決定了 test 的維護成本。</p>
<h3 id="推薦data-testid">推薦：data-testid</h3>
<p>在 HTML 元素上加 <code>data-testid</code> 屬性，Playwright 用 <code>[data-testid=&quot;xxx&quot;]</code> 定位。<code>data-testid</code> 不受 CSS class 改名、文字內容變更、DOM 結構調整影響。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">&lt;</span><span class="nt">button</span> <span class="na">data-testid</span><span class="o">=</span><span class="s">&#34;connect-button&#34;</span><span class="p">&gt;</span>Connect Terminal<span class="p">&lt;/</span><span class="nt">button</span><span class="p">&gt;</span></span></span></code></pre></div><h3 id="可接受文字內容">可接受：文字內容</h3>
<p>用 <code>text=Connect Terminal</code> 定位。在按鈕文字穩定的場景下可用，但多語系支援或文案調整時會斷。</p>
<h3 id="避免css-selector">避免：CSS selector</h3>
<p>用 <code>.btn-primary</code> 或 <code>#main-content &gt; div:nth-child(2)</code> 定位。CSS class 和 DOM 結構的改動頻率高，test 頻繁因無關變更而失敗。</p>
<h2 id="和開發伺服器的整合">和開發伺服器的整合</h2>
<p>Playwright test 需要一個正在運行的 web 應用。整合方式：</p>
<p><strong>手動啟動</strong>：開發者先啟動 dev server，再跑 Playwright test。適合本地開發。</p>
<p><strong>自動啟動</strong>：Playwright 設定檔中指定 <code>webServer</code> 配置，Playwright 自動啟動 dev server，test 結束後自動停止。適合 CI。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// playwright.config.ts
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="kr">export</span> <span class="k">default</span> <span class="nx">defineConfig</span><span class="p">({</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">webServer</span><span class="o">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">command</span><span class="o">:</span> <span class="s1">&#39;npm run dev&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">port</span>: <span class="kt">8080</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">reuseExistingServer</span><span class="o">:</span> <span class="o">!</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">CI</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><h2 id="下一步路由">下一步路由</h2>
<ul>
<li>視覺比對 → <a href="/blog/testing/04-ui-automation/visual-regression/" data-link-title="螢幕截圖比對" data-link-desc="Visual regression testing — 用螢幕截圖比對偵測非預期的視覺變化、baseline 管理和 diff 閾值設定">螢幕截圖比對</a></li>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>導航路徑 test → <a href="/blog/testing/04-ui-automation/navigation-path-test/" data-link-title="導航路徑 test" data-link-desc="Back 按鈕、route 可達性、go vs push 語意 — 驗證使用者能從任何畫面回到預期的位置">導航路徑 test</a></li>
</ul>
]]></content:encoded></item><item><title>模組四：自動化 UI 驗證</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/</guid><description>&lt;p>回答「畫面上的東西是否如設計工作」。狀態矩陣直接轉成 test case。&lt;/p>
&lt;h2 id="待寫章節">待寫章節&lt;/h2>
&lt;ul>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Widget test 的狀態覆蓋策略（從狀態矩陣推導 test case）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 導航路徑 test（back 按鈕、route 可達性）&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> Playwright 瀏覽器驗證流程&lt;/li>
&lt;li>&lt;input checked="" disabled="" type="checkbox"> 螢幕截圖比對（visual regression）&lt;/li>
&lt;/ul>
&lt;h2 id="跨分類引用">跨分類引用&lt;/h2>
&lt;ul>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機&lt;/a>：狀態矩陣是 test case 的 SOT&lt;/li>
&lt;li>← &lt;a href="https://tarrragon.github.io/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式&lt;/a>：go vs push 語意影響 test 斷言&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>回答「畫面上的東西是否如設計工作」。狀態矩陣直接轉成 test case。</p>
<h2 id="待寫章節">待寫章節</h2>
<ul>
<li><input checked="" disabled="" type="checkbox"> Widget test 的狀態覆蓋策略（從狀態矩陣推導 test case）</li>
<li><input checked="" disabled="" type="checkbox"> 導航路徑 test（back 按鈕、route 可達性）</li>
<li><input checked="" disabled="" type="checkbox"> Playwright 瀏覽器驗證流程</li>
<li><input checked="" disabled="" type="checkbox"> 螢幕截圖比對（visual regression）</li>
</ul>
<h2 id="跨分類引用">跨分類引用</h2>
<ul>
<li>← <a href="/blog/ux-design/01-screen-state-machine/" data-link-title="模組一：畫面狀態機設計" data-link-desc="畫面狀態矩陣（顯示 / 操作 / 進入 / 退出）— 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態機</a>：狀態矩陣是 test case 的 SOT</li>
<li>← <a href="/blog/ux-design/05-navigation-patterns/" data-link-title="模組五：導航模式" data-link-desc="Push/pop stack、GoRouter 命名路由、tab bar、drawer — 導航方法選擇是設計決策">ux-design 模組五 導航模式</a>：go vs push 語意影響 test 斷言</li>
</ul>
]]></content:encoded></item><item><title>螢幕截圖比對</title><link>https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/04-ui-automation/visual-regression/</guid><description>&lt;p>螢幕截圖比對（visual regression testing）用基準截圖（baseline）和當前截圖的像素差異來偵測非預期的視覺變化。這一層驗證的是「畫面看起來是否和上次一樣」，捕捉 CSS 變更、layout 偏移、字體替換等邏輯 test 無法發現的視覺問題。&lt;/p>
&lt;h2 id="運作方式">運作方式&lt;/h2>
&lt;h3 id="建立-baseline">建立 baseline&lt;/h3>
&lt;p>第一次執行時擷取每個測試畫面的螢幕截圖，儲存為 baseline。Baseline 代表「目前正確的視覺狀態」。&lt;/p>
&lt;h3 id="比對差異">比對差異&lt;/h3>
&lt;p>後續執行時擷取當前截圖，和 baseline 逐像素比對。差異超過閾值時 test 失敗，產出 diff 圖片標示差異區域。&lt;/p>
&lt;h3 id="更新-baseline">更新 baseline&lt;/h3>
&lt;p>視覺變更是刻意的（新設計、改佈局）時，用新截圖覆蓋 baseline。更新 baseline 是明確的決策 — 代表「新的視覺狀態是正確的」。&lt;/p>
&lt;h2 id="playwright-的截圖比對">Playwright 的截圖比對&lt;/h2>
&lt;p>Playwright 內建 &lt;code>toHaveScreenshot()&lt;/code> 方法：&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;terminal screen matches baseline&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="ln">2&lt;/span>&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;http://localhost:8080/terminal&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&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;terminal-screen&amp;#34;]&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> 
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&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;terminal-connected.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="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">maxDiffPixelRatio&lt;/span>: &lt;span class="kt">0.01&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="c1">// 允許 1% 像素差異
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>首次執行時自動建立 baseline 截圖，後續執行時自動比對。Diff 圖片儲存在 test results 目錄。&lt;/p>
&lt;h2 id="diff-閾值設定">Diff 閾值設定&lt;/h2>
&lt;p>像素比對的閾值影響 test 的敏感度：&lt;/p>
&lt;p>&lt;strong>過低（0.001）&lt;/strong>：anti-aliasing 差異、字體渲染微調、次像素定位變化都會觸發失敗。Test 頻繁因無關變化而失敗（flaky）。&lt;/p>
&lt;p>&lt;strong>過高（0.1）&lt;/strong>：小型 layout 偏移、顏色微調、邊框消失可能不被偵測。Test 的保護力下降。&lt;/p>
&lt;p>&lt;strong>建議起點（0.01）&lt;/strong>：允許 1% 的像素差異。能容忍 anti-aliasing 差異，同時捕捉有意義的視覺變化。根據實際 flaky 頻率調整。&lt;/p>
&lt;h2 id="baseline-管理">Baseline 管理&lt;/h2>
&lt;h3 id="版本控制">版本控制&lt;/h3>
&lt;p>Baseline 截圖加入 git。每次視覺變更的 PR 包含 baseline 更新，reviewer 從 diff 中看到「視覺變化了什麼」。&lt;/p>
&lt;p>Baseline 檔案較大（PNG，數十 KB 到數百 KB）。Git LFS 適合管理這類二進位檔案。&lt;/p>
&lt;h3 id="跨平台差異">跨平台差異&lt;/h3>
&lt;p>不同作業系統的字體渲染、anti-aliasing 演算法不同。同一段 HTML 在 macOS 和 Linux 上的截圖會有微小差異。&lt;/p>
&lt;p>處理策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>一個平台一套 baseline&lt;/strong>：macOS 和 Linux 各自維護 baseline。CI 環境固定在一個平台。&lt;/li>
&lt;li>&lt;strong>只在 CI 比對&lt;/strong>：本地開發不跑截圖比對（平台差異導致 flaky），CI 環境固定平台後比對。&lt;/li>
&lt;/ul>
&lt;h3 id="動態內容">動態內容&lt;/h3>
&lt;p>畫面中有動態內容（時間戳、隨機 ID、動畫）時，截圖每次都不同。&lt;/p>
&lt;p>處理策略：&lt;/p>
&lt;ul>
&lt;li>&lt;strong>遮蔽動態區域&lt;/strong>：截圖前用 CSS 隱藏動態元素，或在截圖比對時指定忽略區域。&lt;/li>
&lt;li>&lt;strong>固定動態值&lt;/strong>：test 中 mock 時間和隨機數，讓畫面內容確定。&lt;/li>
&lt;li>&lt;strong>只截靜態區域&lt;/strong>：用 element screenshot（&lt;code>locator.screenshot()&lt;/code>）而非 full page screenshot，只截不含動態內容的區域。&lt;/li>
&lt;/ul>
&lt;h2 id="和其他-test-層的關係">和其他 test 層的關係&lt;/h2>
&lt;p>截圖比對是 UI test 的最外層 — 驗證視覺呈現而非邏輯行為。它和 widget test（驗證 widget 結構）、導航 test（驗證路由行為）互補：&lt;/p>
&lt;p>widget test 通過但截圖比對失敗 = 邏輯正確但視覺不對（CSS bug）。截圖比對通過但 widget test 失敗 = 視覺沒變但邏輯壞了（功能 bug 還沒影響到視覺）。&lt;/p>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>狀態覆蓋策略 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略&lt;/a>&lt;/li>
&lt;li>Playwright 驗證流程 → &lt;a href="https://tarrragon.github.io/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程&lt;/a>&lt;/li>
&lt;li>畫面狀態矩陣 → &lt;a href="https://tarrragon.github.io/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>螢幕截圖比對（visual regression testing）用基準截圖（baseline）和當前截圖的像素差異來偵測非預期的視覺變化。這一層驗證的是「畫面看起來是否和上次一樣」，捕捉 CSS 變更、layout 偏移、字體替換等邏輯 test 無法發現的視覺問題。</p>
<h2 id="運作方式">運作方式</h2>
<h3 id="建立-baseline">建立 baseline</h3>
<p>第一次執行時擷取每個測試畫面的螢幕截圖，儲存為 baseline。Baseline 代表「目前正確的視覺狀態」。</p>
<h3 id="比對差異">比對差異</h3>
<p>後續執行時擷取當前截圖，和 baseline 逐像素比對。差異超過閾值時 test 失敗，產出 diff 圖片標示差異區域。</p>
<h3 id="更新-baseline">更新 baseline</h3>
<p>視覺變更是刻意的（新設計、改佈局）時，用新截圖覆蓋 baseline。更新 baseline 是明確的決策 — 代表「新的視覺狀態是正確的」。</p>
<h2 id="playwright-的截圖比對">Playwright 的截圖比對</h2>
<p>Playwright 內建 <code>toHaveScreenshot()</code> 方法：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;terminal screen matches baseline&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;http://localhost:8080/terminal&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;[data-testid=&#34;terminal-screen&#34;]&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="k">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">).</span><span class="nx">toHaveScreenshot</span><span class="p">(</span><span class="s1">&#39;terminal-connected.png&#39;</span><span class="p">,</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">maxDiffPixelRatio</span>: <span class="kt">0.01</span><span class="p">,</span>  <span class="c1">// 允許 1% 像素差異
</span></span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="c1"></span>  <span class="p">});</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>首次執行時自動建立 baseline 截圖，後續執行時自動比對。Diff 圖片儲存在 test results 目錄。</p>
<h2 id="diff-閾值設定">Diff 閾值設定</h2>
<p>像素比對的閾值影響 test 的敏感度：</p>
<p><strong>過低（0.001）</strong>：anti-aliasing 差異、字體渲染微調、次像素定位變化都會觸發失敗。Test 頻繁因無關變化而失敗（flaky）。</p>
<p><strong>過高（0.1）</strong>：小型 layout 偏移、顏色微調、邊框消失可能不被偵測。Test 的保護力下降。</p>
<p><strong>建議起點（0.01）</strong>：允許 1% 的像素差異。能容忍 anti-aliasing 差異，同時捕捉有意義的視覺變化。根據實際 flaky 頻率調整。</p>
<h2 id="baseline-管理">Baseline 管理</h2>
<h3 id="版本控制">版本控制</h3>
<p>Baseline 截圖加入 git。每次視覺變更的 PR 包含 baseline 更新，reviewer 從 diff 中看到「視覺變化了什麼」。</p>
<p>Baseline 檔案較大（PNG，數十 KB 到數百 KB）。Git LFS 適合管理這類二進位檔案。</p>
<h3 id="跨平台差異">跨平台差異</h3>
<p>不同作業系統的字體渲染、anti-aliasing 演算法不同。同一段 HTML 在 macOS 和 Linux 上的截圖會有微小差異。</p>
<p>處理策略：</p>
<ul>
<li><strong>一個平台一套 baseline</strong>：macOS 和 Linux 各自維護 baseline。CI 環境固定在一個平台。</li>
<li><strong>只在 CI 比對</strong>：本地開發不跑截圖比對（平台差異導致 flaky），CI 環境固定平台後比對。</li>
</ul>
<h3 id="動態內容">動態內容</h3>
<p>畫面中有動態內容（時間戳、隨機 ID、動畫）時，截圖每次都不同。</p>
<p>處理策略：</p>
<ul>
<li><strong>遮蔽動態區域</strong>：截圖前用 CSS 隱藏動態元素，或在截圖比對時指定忽略區域。</li>
<li><strong>固定動態值</strong>：test 中 mock 時間和隨機數，讓畫面內容確定。</li>
<li><strong>只截靜態區域</strong>：用 element screenshot（<code>locator.screenshot()</code>）而非 full page screenshot，只截不含動態內容的區域。</li>
</ul>
<h2 id="和其他-test-層的關係">和其他 test 層的關係</h2>
<p>截圖比對是 UI test 的最外層 — 驗證視覺呈現而非邏輯行為。它和 widget test（驗證 widget 結構）、導航 test（驗證路由行為）互補：</p>
<p>widget test 通過但截圖比對失敗 = 邏輯正確但視覺不對（CSS bug）。截圖比對通過但 widget test 失敗 = 視覺沒變但邏輯壞了（功能 bug 還沒影響到視覺）。</p>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>狀態覆蓋策略 → <a href="/blog/testing/04-ui-automation/state-coverage-strategy/" data-link-title="Widget test 的狀態覆蓋策略" data-link-desc="從畫面狀態矩陣推導 widget test case — 每個狀態的顯示、操作、退出路徑都是獨立的斷言目標">Widget test 的狀態覆蓋策略</a></li>
<li>Playwright 驗證流程 → <a href="/blog/testing/04-ui-automation/playwright-verification/" data-link-title="Playwright 瀏覽器驗證流程" data-link-desc="用 Playwright 驗證 web 版本的 UI 行為 — test 結構、selector 策略、和 widget test 的互補關係">Playwright 瀏覽器驗證流程</a></li>
<li>畫面狀態矩陣 → <a href="/blog/ux-design/01-screen-state-machine/state-matrix-definition/" data-link-title="畫面狀態矩陣的定義與填寫方法" data-link-desc="四欄矩陣（顯示 / 可用操作 / 進入條件 / 退出路徑）的定義、填寫步驟和檢查規則 — 退出路徑為空 = UX 死胡同">ux-design 模組一 畫面狀態矩陣</a></li>
</ul>
]]></content:encoded></item><item><title>在開發循環裡早一點用 playwright 看真實結果</title><link>https://tarrragon.github.io/blog/report/playwright-early-in-loop/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/playwright-early-in-loop/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>Playwright 不是最後手段、是縮短診斷迴圈的工具。&lt;/strong> 當靜態 CSS 推理 + 視覺截圖溝通的循環失敗 ≥ 2 次、就應該停止推理、改用 playwright &lt;code>browser_evaluate&lt;/code> 直接讀 live DOM 與 computed style。早一點用 = 試錯次數更少、心智負擔更輕。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼推理迴圈有極限">為什麼推理迴圈有極限&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。靜態推理只能基於假設的 DOM tree — 假設錯了、推理就錯。視覺截圖溝通只能傳達「結果是什麼」、無法傳達「為什麼是這個結果」。&lt;/p>
&lt;p>Playwright 的 &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — &lt;strong>把「四個變數」全部變成已知&lt;/strong>。&lt;/p>
&lt;h3 id="推理-vs-量測的成本曲線">推理 vs 量測的成本曲線&lt;/h3>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>第 1 次嘗試&lt;/th>
 &lt;th>第 2 次&lt;/th>
 &lt;th>第 3 次以上&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>靜態推理 + 截圖&lt;/td>
 &lt;td>快 — 假設正確時一次到位&lt;/td>
 &lt;td>慢 — 假設錯了得重來&lt;/td>
 &lt;td>越來越慢 — 假設錯誤累積&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright 量測&lt;/td>
 &lt;td>中 — 起 server、寫 evaluate&lt;/td>
 &lt;td>快 — server 已在跑&lt;/td>
 &lt;td>快 — 重用 setup&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 1 次推理快、後續成本爆炸；playwright 起步慢、後續穩定。&lt;strong>門檻在第 2 次&lt;/strong>。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>要把 search scope UI 放在「搜尋輸入框與結果之間」。&lt;/p>
&lt;p>第一輪：基於 class name 推測 DOM tree、用 grid + display:contents 設 grid-row 排序。第二輪：發現 scope 跑到頁尾、嘗試調 grid-template-rows。第三輪：嘗試 absolute 定位但時機不對。第四輪：使用者說「思路錯了」、要我換方向。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>四輪推理都基於同一個假設：&lt;code>drawer&lt;/code> 是 &lt;code>.pagefind-ui&lt;/code> 的直接子節點、跟 &lt;code>form&lt;/code> 並列。實際用 playwright 一查：&lt;/p>





&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">const&lt;/span> &lt;span class="nx">drawer&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">&lt;span class="kd">let&lt;/span> &lt;span class="nx">parents&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">&lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">el&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 class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">parents&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">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">el&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&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;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-text" data-lang="text">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">DIV.pagefind-ui__drawer
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">FORM.pagefind-ui__form ← drawer 在 form 內！
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">DIV.pagefind-ui&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>假設錯了 — drawer 是 form 的 child、不是 sibling。grid 規則無論怎麼寫都不會生效，因為 drawer 跟 form 共用同一個 grid cell。&lt;/p>
&lt;p>四輪推理 ≈ 30 分鐘。Playwright 一次查清楚 ≈ 2 分鐘。&lt;/p>
&lt;h3 id="執行">執行&lt;/h3>
&lt;p>確認 DOM 結構後：grid 不適合這個場景、改用 absolute + drawer margin-top spacer。一次到位。&lt;/p>
&lt;hr>
&lt;h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置&lt;/h2>
&lt;h3 id="1-假設驗證">1. 假設驗證&lt;/h3>
&lt;p>寫 CSS 規則前先量 DOM、確認結構符合假設。&lt;/p>





&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">parents&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="p">[].&lt;/span>&lt;span class="nx">slice&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">call&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&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="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">)&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">chain&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">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="s1">&amp;#39;.&amp;#39;&lt;/span> &lt;span class="o">+&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="p">);&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&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="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="p">})&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&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>Layout 規則寫完後驗證實際結果。&lt;/p>





&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">rect&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">computed&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&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;.target&amp;#39;&lt;/span>&lt;span class="p">)).&lt;/span>&lt;span class="nx">gridRow&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&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>驗證使用者互動後的狀態。&lt;/p>





&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">input&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">value&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">input&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">dispatchEvent&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="k">new&lt;/span> &lt;span class="nx">Event&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">{&lt;/span> &lt;span class="nx">bubbles&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="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="k">new&lt;/span> &lt;span class="nb">Promise&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">setTimeout&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">r&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;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">Array&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">from&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nb">document&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">))&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">filter&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">display&lt;/span> &lt;span class="o">!==&lt;/span> &lt;span class="s1">&amp;#39;none&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="p">.&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">textContent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&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">50&lt;/span>&lt;span class="p">));&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&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="內在屬性比較四種-debug-方法">內在屬性比較：四種 debug 方法&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>取得資訊量&lt;/th>
 &lt;th>重複成本&lt;/th>
 &lt;th>可寫成測試&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>靜態 CSS 推理&lt;/td>
 &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;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>瀏覽器 DevTools&lt;/td>
 &lt;td>高 — DOM + computed&lt;/td>
 &lt;td>中 — 每次手點&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;td>最高 — 程式化任意查詢&lt;/td>
 &lt;td>低 — 改 query 重跑&lt;/td>
 &lt;td>是 — 同樣 query 可寫測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>選擇順序：&lt;strong>簡單 layout 用 DevTools；複雜 / 反覆 debug 用 playwright；推理只在第 1 次試錯前&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>Playwright 不是最後手段、是縮短診斷迴圈的工具。</strong> 當靜態 CSS 推理 + 視覺截圖溝通的循環失敗 ≥ 2 次、就應該停止推理、改用 playwright <code>browser_evaluate</code> 直接讀 live DOM 與 computed style。早一點用 = 試錯次數更少、心智負擔更輕。</p>
<hr>
<h2 id="為什麼推理迴圈有極限">為什麼推理迴圈有極限</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。靜態推理只能基於假設的 DOM tree — 假設錯了、推理就錯。視覺截圖溝通只能傳達「結果是什麼」、無法傳達「為什麼是這個結果」。</p>
<p>Playwright 的 <code>browser_evaluate</code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — <strong>把「四個變數」全部變成已知</strong>。</p>
<h3 id="推理-vs-量測的成本曲線">推理 vs 量測的成本曲線</h3>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>第 1 次嘗試</th>
          <th>第 2 次</th>
          <th>第 3 次以上</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態推理 + 截圖</td>
          <td>快 — 假設正確時一次到位</td>
          <td>慢 — 假設錯了得重來</td>
          <td>越來越慢 — 假設錯誤累積</td>
      </tr>
      <tr>
          <td>Playwright 量測</td>
          <td>中 — 起 server、寫 evaluate</td>
          <td>快 — server 已在跑</td>
          <td>快 — 重用 setup</td>
      </tr>
  </tbody>
</table>
<p>第 1 次推理快、後續成本爆炸；playwright 起步慢、後續穩定。<strong>門檻在第 2 次</strong>。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p>要把 search scope UI 放在「搜尋輸入框與結果之間」。</p>
<p>第一輪：基於 class name 推測 DOM tree、用 grid + display:contents 設 grid-row 排序。第二輪：發現 scope 跑到頁尾、嘗試調 grid-template-rows。第三輪：嘗試 absolute 定位但時機不對。第四輪：使用者說「思路錯了」、要我換方向。</p>
<h3 id="判讀">判讀</h3>
<p>四輪推理都基於同一個假設：<code>drawer</code> 是 <code>.pagefind-ui</code> 的直接子節點、跟 <code>form</code> 並列。實際用 playwright 一查：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kd">let</span> <span class="nx">parents</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">el</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="k">while</span> <span class="p">(</span><span class="nx">el</span> <span class="o">&amp;&amp;</span> <span class="nx">el</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">parents</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">el</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">el</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="nx">el</span> <span class="o">=</span> <span class="nx">el</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>返回：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">DIV.pagefind-ui__drawer
</span></span><span class="line"><span class="ln">2</span><span class="cl">FORM.pagefind-ui__form    ← drawer 在 form 內！
</span></span><span class="line"><span class="ln">3</span><span class="cl">DIV.pagefind-ui</span></span></code></pre></div><p>假設錯了 — drawer 是 form 的 child、不是 sibling。grid 規則無論怎麼寫都不會生效，因為 drawer 跟 form 共用同一個 grid cell。</p>
<p>四輪推理 ≈ 30 分鐘。Playwright 一次查清楚 ≈ 2 分鐘。</p>
<h3 id="執行">執行</h3>
<p>確認 DOM 結構後：grid 不適合這個場景、改用 absolute + drawer margin-top spacer。一次到位。</p>
<hr>
<h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置</h2>
<h3 id="1-假設驗證">1. 假設驗證</h3>
<p>寫 CSS 規則前先量 DOM、確認結構符合假設。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">parents</span><span class="o">:</span> <span class="p">[].</span><span class="nx">slice</span><span class="p">.</span><span class="nx">call</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="k">while</span> <span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span> <span class="o">+</span> <span class="s1">&#39;.&#39;</span> <span class="o">+</span> <span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="p">})</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><h3 id="2-行為驗證">2. 行為驗證</h3>
<p>Layout 規則寫完後驗證實際結果。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">rect</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">computed</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">gridRow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><h3 id="3-互動驗證">3. 互動驗證</h3>
<p>驗證使用者互動後的狀態。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="內在屬性比較四種-debug-方法">內在屬性比較：四種 debug 方法</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>取得資訊量</th>
          <th>重複成本</th>
          <th>可寫成測試</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態 CSS 推理</td>
          <td>低 — 全是假設</td>
          <td>高 — 每次重思考</td>
          <td>否</td>
      </tr>
      <tr>
          <td>視覺截圖溝通</td>
          <td>中 — 只有結果</td>
          <td>中 — 截圖 / 描述慢</td>
          <td>否</td>
      </tr>
      <tr>
          <td>瀏覽器 DevTools</td>
          <td>高 — DOM + computed</td>
          <td>中 — 每次手點</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Playwright <code>browser_evaluate</code></td>
          <td>最高 — 程式化任意查詢</td>
          <td>低 — 改 query 重跑</td>
          <td>是 — 同樣 query 可寫測試</td>
      </tr>
  </tbody>
</table>
<p>選擇順序：<strong>簡單 layout 用 DevTools；複雜 / 反覆 debug 用 playwright；推理只在第 1 次試錯前</strong>。</p>
<hr>
<h2 id="引入-playwright-的最低門檻">引入 playwright 的最低門檻</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 啟動本地 server（任何方式）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public
</span></span><span class="line"><span class="ln">3</span><span class="cl">
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"># 或專案有 hugo</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">hugo server</span></span></code></pre></div><p>Playwright MCP 提供：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
</ul>
<p>寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。</p>
<hr>
<h2 id="設計取捨css--dom-debug-工具選擇">設計取捨：CSS / DOM debug 工具選擇</h2>
<p>四種做法、各自機會成本不同。這個專案在推理 ≥ 2 次失敗後選 A（playwright <code>browser_evaluate</code>）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「debug 工具切換」這個面向的應用。</p></blockquote>
<h3 id="aplaywright-browser_evaluate-程式化讀-live-dom這個專案的預設">A：Playwright <code>browser_evaluate</code> 程式化讀 live DOM（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：起 server、用 <code>browser_evaluate</code> 寫 JS query 讀 DOM tree / computed style / bounding rect</li>
<li><strong>選 A 的理由</strong>：取得資訊量最大、可重跑、可寫成測試</li>
<li><strong>適合</strong>：推理失敗 ≥ 2 次、複雜或反覆 debug 的情境</li>
<li><strong>代價</strong>：起步成本中（需要 server + 寫 evaluate）</li>
</ul>
<h3 id="b靜態-css-推理--視覺截圖溝通">B：靜態 CSS 推理 + 視覺截圖溝通</h3>
<ul>
<li><strong>機制</strong>：純看 CSS 與假設的 DOM 推測、用截圖跟使用者溝通</li>
<li><strong>跟 A 的取捨</strong>：B 起步成本 0、A 起步成本中；但 B 第 2 次以後成本爆炸（每輪都基於前輪錯誤假設）</li>
<li><strong>B 比 A 好的情境</strong>：第 1 次嘗試、預估假設正確機率高（簡單修改）</li>
</ul>
<h3 id="c瀏覽器-devtools-手動查">C：瀏覽器 DevTools 手動查</h3>
<ul>
<li><strong>機制</strong>：開 DevTools 切 Elements / Computed / Layout 面板手動探索</li>
<li><strong>跟 A 的取捨</strong>：C 不需 server / playwright setup、但每次手點切面板慢、不能寫成測試</li>
<li><strong>C 比 A 好的情境</strong>：一次性確認、不需要重複 query 同樣資訊</li>
</ul>
<h3 id="d寫成-playwright-測試固化">D：寫成 playwright 測試固化</h3>
<ul>
<li><strong>機制</strong>：把 debug 過程寫成 playwright 測試、未來自動跑</li>
<li><strong>跟 A 的取捨</strong>：D 是 A 的延伸 — 第 2 次 debug 同個版型時、值得固化（<a href="../layout-tests-with-playwright/">#15 layout tests</a>）</li>
<li><strong>D 比 A 好的情境</strong>：版型 bug 出現第 2 次以上、值得寫測試防止回歸</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>工具切換時機</th>
          <th>第一個該寫的 evaluate</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 ≥ 2 次失敗</td>
          <td>切到 playwright</td>
          <td>量目標元素的 ancestor chain</td>
      </tr>
      <tr>
          <td>Layout 在某些狀態下錯、其他狀態下對</td>
          <td>切到 playwright</td>
          <td>量該元素在不同狀態下的 bounding rect</td>
      </tr>
      <tr>
          <td>改 CSS 不生效、specificity 看起來對</td>
          <td>切到 playwright</td>
          <td>量 computed style 看真正套到的值</td>
      </tr>
      <tr>
          <td>動態 DOM 結構不確定</td>
          <td>切到 playwright</td>
          <td>列出目標 container 的子節點</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：縮短診斷迴圈的工具該早一點用、不該等到推理徹底失敗。第 2 次推理失敗就切換、別等第 5 次。</p>
<p>延伸應用：playwright 也用來查「資料層 vs 視覺層的層錯位」 — 見 <a href="../view-layer-filter-vs-source-layer/">#55 Filter 與 Source 的抽象層錯位</a> 用 <code>browser_evaluate</code> 量 source 真實 cardinality 與分批機制。</p>
]]></content:encoded></item><item><title>用前端測試把排版問題自動化</title><link>https://tarrragon.github.io/blog/report/layout-tests-with-playwright/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/layout-tests-with-playwright/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>當一個版型被 debug 兩次以上、就值得寫成 playwright 測試。&lt;/strong> 測試替代「手動檢查 + 截圖」的循環、讓版型回歸可被機器發現。下次有人改 CSS 時、測試會立刻指出哪個假設被破壞。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼版型問題適合自動化">為什麼版型問題適合自動化&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>排版問題的特徵：&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>邊界條件多（viewport、字型、互動狀態）&lt;/td>
 &lt;td>人眼難以涵蓋全部組合&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>變動觸發點不明顯（改 token、改 theme）&lt;/td>
 &lt;td>改一處不知道哪裡會壞&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺問題往往來自相對關係&lt;/td>
 &lt;td>截圖只看絕對位置、看不出關係&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>人腦適合「驚訝時注意」、不適合「重複檢查 100 個 case 是否如預期」。後者是機器擅長的。&lt;/p>
&lt;h3 id="兩種測試層次">兩種測試層次&lt;/h3>
&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;/td>
 &lt;td>整頁與基準截圖比對&lt;/td>
 &lt;td>Percy / Chromatic / Playwright snapshot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結構斷言&lt;/td>
 &lt;td>特定元素的位置 / 尺寸 / 順序&lt;/td>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code> + &lt;code>expect&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>兩種互補。視覺迴歸抓「整頁有沒有變」、結構斷言抓「特定關係有沒有保持」。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的測試機會">這次任務的測試機會&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>搜尋頁的版型在這次開發中被 debug 多輪：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>場景&lt;/th>
 &lt;th>Debug 次數&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>Filter sidebar 跨 viewport 顯示 / 隱藏&lt;/td>
 &lt;td>5+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Scope UI 三狀態下的位置&lt;/td>
 &lt;td>4+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>結果區跟 sidebar 頂端對齊&lt;/td>
 &lt;td>3+&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Filter 順序 type 在前&lt;/td>
 &lt;td>2&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>每個 debug 過 ≥ 2 次的版型場景都值得寫測試 — 表示這個地方很容易壞、未來改 CSS 還會踩。&lt;/p>
&lt;h3 id="執行寫-playwright-測試">執行：寫 playwright 測試&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="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="c1">// tests/search-layout.spec.js
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 2&lt;/span>&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">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="nx">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="ln"> 3&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&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;search page layout&amp;#39;&lt;/span>&lt;span class="p">,&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="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;desktop ≥ 1400 顯示左側 filter sidebar&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="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1440&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;/blog/search/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">slot&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">$&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">12&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">isVisible&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">slot&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">isVisible&lt;/span>&lt;span class="p">();&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">13&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">isVisible&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBe&lt;/span>&lt;span class="p">(&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="ln">14&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">15&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">filterParent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">16&lt;/span>&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;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">17&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">18&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterParent&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toContain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;search-filter-slot&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">19&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">20&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">21&lt;/span>&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;viewport &amp;lt; 1400 filter 留在 pagefind drawer&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="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">22&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1024&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">23&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;/blog/search/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">24&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">25&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">26&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">27&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">filterParent&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(()&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">28&lt;/span>&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;.pagefind-ui__filter-panel&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">29&lt;/span>&lt;span class="cl"> &lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">30&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">filterParent&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toContain&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">31&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">32&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">33&lt;/span>&lt;span class="cl"> &lt;span class="nx">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;scope UI 在三互動狀態下都在 input 與 results 之間&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="p">=&amp;gt;&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">34&lt;/span>&lt;span class="cl"> &lt;span class="kr">await&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">setViewportSize&lt;/span>&lt;span class="p">({&lt;/span> &lt;span class="nx">width&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">1440&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">height&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="mi">900&lt;/span> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">35&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;/blog/search/&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">36&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">37&lt;/span>&lt;span class="cl"> &lt;span class="kr">async&lt;/span> &lt;span class="kd">function&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">selector&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="ln">38&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">page&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">evaluate&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">s&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &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="nx">s&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">().&lt;/span>&lt;span class="nx">y&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="nx">selector&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">39&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">40&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">41&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 1：初始載入
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">42&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">43&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">44&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">45&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">46&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 2：點 input
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">47&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">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;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">48&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">49&lt;/span>&lt;span class="cl"> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">50&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">51&lt;/span>&lt;span class="cl">
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">52&lt;/span>&lt;span class="cl"> &lt;span class="c1">// 狀態 3：輸入字
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">53&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="kr">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;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="s1">&amp;#39;pre&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">54&lt;/span>&lt;span class="cl"> &lt;span class="kr">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;.pagefind-ui__results .pagefind-ui__result&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">55&lt;/span>&lt;span class="cl"> &lt;span class="nx">scopeY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.search-scope&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">56&lt;/span>&lt;span class="cl"> &lt;span class="nx">inputY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__search-input&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">57&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">resultsY&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="kr">await&lt;/span> &lt;span class="nx">getY&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__results&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">58&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeGreaterThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">inputY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">59&lt;/span>&lt;span class="cl"> &lt;span class="nx">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">scopeY&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">toBeLessThan&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">resultsY&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">60&lt;/span>&lt;span class="cl"> &lt;span class="p">});&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">61&lt;/span>&lt;span class="cl">&lt;span class="p">});&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>每個 &lt;code>expect&lt;/code> 對應一條版型契約 — 這條被破壞時測試紅、改 CSS 的人立刻知道。&lt;/p>
&lt;hr>
&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;th>Playwright 測試&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &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;tr>
 &lt;td>涵蓋率&lt;/td>
 &lt;td>低 — 受人記憶限制&lt;/td>
 &lt;td>高 — 跑所有 case&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規範化&lt;/td>
 &lt;td>否 — 知識在腦中&lt;/td>
 &lt;td>是 — 寫成可讀的 expect&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;p>第 1 次寫成本中、第 2 次以後成本碾壓手動。&lt;strong>門檻在「會 debug 第 2 次嗎」&lt;/strong>。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>當一個版型被 debug 兩次以上、就值得寫成 playwright 測試。</strong> 測試替代「手動檢查 + 截圖」的循環、讓版型回歸可被機器發現。下次有人改 CSS 時、測試會立刻指出哪個假設被破壞。</p>
<hr>
<h2 id="為什麼版型問題適合自動化">為什麼版型問題適合自動化</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>排版問題的特徵：</p>
<table>
  <thead>
      <tr>
          <th>特徵</th>
          <th>對手動檢查的不利</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>邊界條件多（viewport、字型、互動狀態）</td>
          <td>人眼難以涵蓋全部組合</td>
      </tr>
      <tr>
          <td>變動觸發點不明顯（改 token、改 theme）</td>
          <td>改一處不知道哪裡會壞</td>
      </tr>
      <tr>
          <td>視覺問題往往來自相對關係</td>
          <td>截圖只看絕對位置、看不出關係</td>
      </tr>
  </tbody>
</table>
<p>人腦適合「驚訝時注意」、不適合「重複檢查 100 個 case 是否如預期」。後者是機器擅長的。</p>
<h3 id="兩種測試層次">兩種測試層次</h3>
<table>
  <thead>
      <tr>
          <th>層次</th>
          <th>測什麼</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>視覺迴歸</td>
          <td>整頁與基準截圖比對</td>
          <td>Percy / Chromatic / Playwright snapshot</td>
      </tr>
      <tr>
          <td>結構斷言</td>
          <td>特定元素的位置 / 尺寸 / 順序</td>
          <td>Playwright <code>browser_evaluate</code> + <code>expect</code></td>
      </tr>
  </tbody>
</table>
<p>兩種互補。視覺迴歸抓「整頁有沒有變」、結構斷言抓「特定關係有沒有保持」。</p>
<hr>
<h2 id="這次任務的測試機會">這次任務的測試機會</h2>
<h3 id="觀察">觀察</h3>
<p>搜尋頁的版型在這次開發中被 debug 多輪：</p>
<table>
  <thead>
      <tr>
          <th>場景</th>
          <th>Debug 次數</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Filter sidebar 跨 viewport 顯示 / 隱藏</td>
          <td>5+</td>
      </tr>
      <tr>
          <td>Scope UI 三狀態下的位置</td>
          <td>4+</td>
      </tr>
      <tr>
          <td>結果區跟 sidebar 頂端對齊</td>
          <td>3+</td>
      </tr>
      <tr>
          <td>Filter 順序 type 在前</td>
          <td>2</td>
      </tr>
  </tbody>
</table>
<h3 id="判讀">判讀</h3>
<p>每個 debug 過 ≥ 2 次的版型場景都值得寫測試 — 表示這個地方很容易壞、未來改 CSS 還會踩。</p>
<h3 id="執行寫-playwright-測試">執行：寫 playwright 測試</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// tests/search-layout.spec.js
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@playwright/test&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="nx">test</span><span class="p">.</span><span class="nx">describe</span><span class="p">(</span><span class="s1">&#39;search page layout&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;desktop ≥ 1400 顯示左側 filter sidebar&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1440</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">    <span class="kr">const</span> <span class="nx">slot</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">$</span><span class="p">(</span><span class="s1">&#39;.search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">    <span class="kr">const</span> <span class="nx">isVisible</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">slot</span><span class="p">.</span><span class="nx">isVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">isVisible</span><span class="p">).</span><span class="nx">toBe</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl">    <span class="kr">const</span> <span class="nx">filterParent</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">).</span><span class="nx">parentElement</span><span class="p">.</span><span class="nx">className</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">    <span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">filterParent</span><span class="p">).</span><span class="nx">toContain</span><span class="p">(</span><span class="s1">&#39;search-filter-slot&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;viewport &lt; 1400 filter 留在 pagefind drawer&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1024</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">26</span><span class="cl">
</span></span><span class="line"><span class="ln">27</span><span class="cl">    <span class="kr">const</span> <span class="nx">filterParent</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span>
</span></span><span class="line"><span class="ln">28</span><span class="cl">      <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__filter-panel&#39;</span><span class="p">).</span><span class="nx">parentElement</span><span class="p">.</span><span class="nx">className</span>
</span></span><span class="line"><span class="ln">29</span><span class="cl">    <span class="p">);</span>
</span></span><span class="line"><span class="ln">30</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">filterParent</span><span class="p">).</span><span class="nx">toContain</span><span class="p">(</span><span class="s1">&#39;pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">31</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">32</span><span class="cl">
</span></span><span class="line"><span class="ln">33</span><span class="cl">  <span class="nx">test</span><span class="p">(</span><span class="s1">&#39;scope UI 在三互動狀態下都在 input 與 results 之間&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">34</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1440</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">900</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">35</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/blog/search/&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">36</span><span class="cl">
</span></span><span class="line"><span class="ln">37</span><span class="cl">    <span class="kr">async</span> <span class="kd">function</span> <span class="nx">getY</span><span class="p">(</span><span class="nx">selector</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">38</span><span class="cl">      <span class="k">return</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(</span><span class="nx">s</span> <span class="p">=&gt;</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="nx">s</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">().</span><span class="nx">y</span><span class="p">,</span> <span class="nx">selector</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">39</span><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="ln">40</span><span class="cl">
</span></span><span class="line"><span class="ln">41</span><span class="cl">    <span class="c1">// 狀態 1：初始載入
</span></span></span><span class="line"><span class="ln">42</span><span class="cl"><span class="c1"></span>    <span class="kd">let</span> <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">43</span><span class="cl">    <span class="kd">let</span> <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">44</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">45</span><span class="cl">
</span></span><span class="line"><span class="ln">46</span><span class="cl">    <span class="c1">// 狀態 2：點 input
</span></span></span><span class="line"><span class="ln">47</span><span class="cl"><span class="c1"></span>    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">click</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">48</span><span class="cl">    <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">49</span><span class="cl">    <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">50</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">51</span><span class="cl">
</span></span><span class="line"><span class="ln">52</span><span class="cl">    <span class="c1">// 狀態 3：輸入字
</span></span></span><span class="line"><span class="ln">53</span><span class="cl"><span class="c1"></span>    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">fill</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">,</span> <span class="s1">&#39;pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">54</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results .pagefind-ui__result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">55</span><span class="cl">    <span class="nx">scopeY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.search-scope&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">56</span><span class="cl">    <span class="nx">inputY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">57</span><span class="cl">    <span class="kr">const</span> <span class="nx">resultsY</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">getY</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__results&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">58</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">inputY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">59</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeY</span><span class="p">).</span><span class="nx">toBeLessThan</span><span class="p">(</span><span class="nx">resultsY</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">60</span><span class="cl">  <span class="p">});</span>
</span></span><span class="line"><span class="ln">61</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>每個 <code>expect</code> 對應一條版型契約 — 這條被破壞時測試紅、改 CSS 的人立刻知道。</p>
<hr>
<h2 id="測試的維護成本與收益">測試的維護成本與收益</h2>
<h3 id="內在屬性比較">內在屬性比較</h3>
<table>
  <thead>
      <tr>
          <th>屬性</th>
          <th>手動檢查</th>
          <th>Playwright 測試</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>首次成本</td>
          <td>低 — 開頁面看</td>
          <td>中 — 寫測試</td>
      </tr>
      <tr>
          <td>重複成本</td>
          <td>高 — 每次回歸都要全部看</td>
          <td>低 — 自動跑</td>
      </tr>
      <tr>
          <td>涵蓋率</td>
          <td>低 — 受人記憶限制</td>
          <td>高 — 跑所有 case</td>
      </tr>
      <tr>
          <td>規範化</td>
          <td>否 — 知識在腦中</td>
          <td>是 — 寫成可讀的 expect</td>
      </tr>
      <tr>
          <td>教學價值</td>
          <td>低 — 新人需要被告知</td>
          <td>高 — 測試本身是文件</td>
      </tr>
  </tbody>
</table>
<p>第 1 次寫成本中、第 2 次以後成本碾壓手動。<strong>門檻在「會 debug 第 2 次嗎」</strong>。</p>
<hr>
<h2 id="測試什麼不測什麼">測試什麼、不測什麼</h2>
<h3 id="適合測試的版型場景">適合測試的版型場景</h3>
<ul>
<li>跨 viewport 的元件顯示 / 隱藏切換</li>
<li>元件相對位置（A 在 B 上方 / 下方 / 左右）</li>
<li>元件順序（type 在 tag 前）</li>
<li>互動狀態下的位置不變（scope 在三狀態下都在 input 與 results 之間）</li>
</ul>
<h3 id="不適合用-playwright-測">不適合用 playwright 測</h3>
<ul>
<li>純視覺差異（顏色微差、圓角 1px 差） — 用 visual regression 工具</li>
<li>動畫過程 — 不穩定、容易 flaky</li>
<li>字型 rendering 細節 — 跨 OS / 瀏覽器差異大</li>
</ul>
<p>選擇原則：<strong>測「結構性契約」、不測「畫素」</strong>。畫素級檢查交給 visual regression。</p>
<hr>
<h2 id="設計取捨版型驗證機制的選擇">設計取捨：版型驗證機制的選擇</h2>
<p>四種做法、各自機會成本不同。這個專案在版型 debug ≥ 2 次後選 A（結構斷言測試）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「驗證機制升級」這個面向的應用。</p></blockquote>
<h3 id="aplaywright-結構斷言測試這個專案的預設">A：Playwright 結構斷言測試（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：寫 <code>expect(scopeY &gt; inputY)</code> 這類斷言、自動跑、跨字型 / 主題都對</li>
<li><strong>選 A 的理由</strong>：規範化（測試本身是文件）、跨環境穩定、回歸自動偵測</li>
<li><strong>適合</strong>：debug ≥ 2 次的版型場景、需要長期保護的 layout 契約</li>
<li><strong>代價</strong>：寫測試的初始成本、需要 playwright runtime</li>
</ul>
<h3 id="b手動截圖檢查">B：手動截圖檢查</h3>
<ul>
<li><strong>機制</strong>：開頁面、看截圖、人眼確認</li>
<li><strong>跟 A 的取捨</strong>：B 起步成本 0、A 起步成本中；但 B 重複成本高（每次回歸都要看）</li>
<li><strong>B 比 A 好的情境</strong>：第 1 次驗證（debug 過 1 次、不確定值不值得寫測試）、純探索期</li>
</ul>
<h3 id="cvisual-regression-snapshot">C：Visual regression snapshot</h3>
<ul>
<li><strong>機制</strong>：截整頁圖跟 baseline 比對、像素級差異</li>
<li><strong>跟 A 的取捨</strong>：C 涵蓋率廣（整頁所有變動都偵測）、A 只測指定契約；但 C false positive 多（字型微調 / theme 換色都觸發）</li>
<li><strong>C 比 A 好的情境</strong>：純視覺驗證（marketing page）、設計穩定不常改</li>
</ul>
<h3 id="d不寫測試">D：不寫測試</h3>
<ul>
<li><strong>機制</strong>：純信任手動驗證</li>
<li><strong>跟 A 的取捨</strong>：D 0 維護成本、A 有測試維護；但 D 在版型反覆壞時累積「腦中知識」、新人接手不知道</li>
<li><strong>D 才合理的情境</strong>：純探索期 / prototype、確定不上 production</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該寫測試的時機</th>
          <th>第一個該寫的 expect</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同一個版型 bug 出現第 2 次</td>
          <td>立刻寫</td>
          <td>把當時的 fix 寫成 expect</td>
      </tr>
      <tr>
          <td>改 token / theme 時不確定哪些頁面會壞</td>
          <td>把對 token 敏感的頁面寫測試</td>
          <td>元件相對位置、寬度比例</td>
      </tr>
      <tr>
          <td>跨 viewport 的響應式邏輯複雜</td>
          <td>寫 viewport 切換測試</td>
          <td>不同寬度下元件顯示 / 位置</td>
      </tr>
      <tr>
          <td>互動狀態下版型不穩定</td>
          <td>寫狀態切換測試</td>
          <td>各狀態下關鍵元素的位置關係</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：版型契約用測試固定 — 測試紅了表示契約被打破、不是測試壞了。每個紅色測試都是有人改了不該改的東西的訊號。</p>
<p>跟 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> 的關係：layout test 是 Checkpoint 3「Ship 前」的具體做法 — 跨 viewport / 跨狀態 / 跨資料規模驗收、catch 開發中 checkpoint 看不到的整合錯。沒寫 layout test = 把 ship 前 checkpoint 跳過、所有版型回歸都進 ship 後（使用者反映才修）。</p>
<p>寫完 layout test 必須在「未修版型」跑 RED 確認測試會抓到該抓的、再在「修後版型」跑 GREEN 確認修對了 — 兩個訊號都看到、測試才被驗證。詳見 <a href="../test-first-red-before-green/">#69 Test-First：先看到 RED 才相信 GREEN</a>。</p>
]]></content:encoded></item><item><title>驗證方法的選擇時機</title><link>https://tarrragon.github.io/blog/report/verification-method-timing/</link><pubDate>Sat, 25 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/report/verification-method-timing/</guid><description>&lt;h2 id="核心原則">核心原則&lt;/h2>
&lt;p>&lt;strong>驗證工具的引入時機不該等推理徹底失敗。&lt;/strong> 靜態 CSS 推理或視覺截圖溝通連續失敗 ≥ 2 次、立刻主動提「我們啟個 server、我用 playwright 看 live DOM」 — 工具的價值是縮短診斷迴圈、不是最後手段。&lt;/p>
&lt;hr>
&lt;h2 id="為什麼要主動提工具">為什麼要主動提工具&lt;/h2>
&lt;h3 id="商業邏輯">商業邏輯&lt;/h3>
&lt;p>執行者堅持靠推理 = 把使用者拖進「截圖 - 反饋 - 再試」的長循環。每輪都消耗使用者時間（看截圖、描述問題、回應）— 對使用者是負擔。&lt;/p>
&lt;p>主動提工具切換 = 把循環從「視覺溝通」改成「程式量測」。執行者直接讀 live DOM、診斷一輪到位、使用者只需要在最終確認。&lt;/p>
&lt;p>主動提的成本是「打一句話建議」、收益是「省 N 輪截圖溝通」。&lt;/p>
&lt;hr>
&lt;h2 id="這次任務的實際情境">這次任務的實際情境&lt;/h2>
&lt;h3 id="觀察">觀察&lt;/h3>
&lt;p>&lt;code>drawer 在 form 內、不是 sibling&lt;/code> 這個假設錯誤、靠推理 + 截圖溝通走了多輪：&lt;/p>
&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>1&lt;/td>
 &lt;td>推理 + 寫 CSS + 使用者截圖回報&lt;/td>
 &lt;td>失敗、看不出根因&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>改 CSS + 使用者截圖回報&lt;/td>
 &lt;td>失敗、累積錯誤假設&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>加更多覆寫 + 使用者截圖回報&lt;/td>
 &lt;td>失敗、使用者「思路錯了」&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>4&lt;/td>
 &lt;td>「我啟個 server 看看」&lt;/td>
 &lt;td>立刻發現 drawer 在 form 內&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>第 4 輪用 playwright &lt;code>browser_evaluate&lt;/code> 讀 ancestor chain — 一個 query、一個答案、兩分鐘解。前三輪 ≈ 30 分鐘。&lt;/p>
&lt;h3 id="判讀">判讀&lt;/h3>
&lt;p>第 2 輪失敗時就應該主動提：&lt;/p>
&lt;blockquote>
&lt;p>「我嘗試了兩次都失敗、根因可能在我對 DOM 結構的假設。要不要啟個 server、我用 playwright 直接讀 live DOM 確認？這樣比繼續用截圖溝通快。」&lt;/p>&lt;/blockquote>
&lt;p>使用者啟 server、我跑 query、一輪解。&lt;/p>
&lt;h3 id="執行主動提工具的-protocol">執行：主動提工具的 protocol&lt;/h3>
&lt;p>驗證工具該在這些時機主動提：&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>推理連續失敗 ≥ 2 次&lt;/td>
 &lt;td>playwright &lt;code>browser_evaluate&lt;/code> 讀 live DOM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定元素的真實位置&lt;/td>
 &lt;td>&lt;code>getBoundingClientRect&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 computed style 套到什麼值&lt;/td>
 &lt;td>&lt;code>getComputedStyle&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 framework 渲染後的 DOM&lt;/td>
 &lt;td>playwright snapshot&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定跨 viewport 行為&lt;/td>
 &lt;td>playwright 切換 viewport 重測&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&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;th>每輪成本&lt;/th>
 &lt;th>涵蓋&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>推理 + 截圖&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>高 — 截圖、描述、再試&lt;/td>
 &lt;td>有限 — 看截圖看不到 DOM&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>瀏覽器 DevTools 手動查&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>中 — 切面板、讀&lt;/td>
 &lt;td>中 — 互動成本高&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;td>中 — 起 server&lt;/td>
 &lt;td>低 — 寫一段 evaluate&lt;/td>
 &lt;td>高 — 任意 JS query&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright 寫成測試&lt;/td>
 &lt;td>中 — 起 server + 寫測試&lt;/td>
 &lt;td>0 — 自動跑&lt;/td>
 &lt;td>高 + 持續&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>「起步成本」是一次性、「每輪成本」是重複的。第 2 輪以後、playwright 的 ROI 已經正向。&lt;/p></description><content:encoded><![CDATA[<h2 id="核心原則">核心原則</h2>
<p><strong>驗證工具的引入時機不該等推理徹底失敗。</strong> 靜態 CSS 推理或視覺截圖溝通連續失敗 ≥ 2 次、立刻主動提「我們啟個 server、我用 playwright 看 live DOM」 — 工具的價值是縮短診斷迴圈、不是最後手段。</p>
<hr>
<h2 id="為什麼要主動提工具">為什麼要主動提工具</h2>
<h3 id="商業邏輯">商業邏輯</h3>
<p>執行者堅持靠推理 = 把使用者拖進「截圖 - 反饋 - 再試」的長循環。每輪都消耗使用者時間（看截圖、描述問題、回應）— 對使用者是負擔。</p>
<p>主動提工具切換 = 把循環從「視覺溝通」改成「程式量測」。執行者直接讀 live DOM、診斷一輪到位、使用者只需要在最終確認。</p>
<p>主動提的成本是「打一句話建議」、收益是「省 N 輪截圖溝通」。</p>
<hr>
<h2 id="這次任務的實際情境">這次任務的實際情境</h2>
<h3 id="觀察">觀察</h3>
<p><code>drawer 在 form 內、不是 sibling</code> 這個假設錯誤、靠推理 + 截圖溝通走了多輪：</p>
<table>
  <thead>
      <tr>
          <th>輪</th>
          <th>溝通方式</th>
          <th>結果</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td>推理 + 寫 CSS + 使用者截圖回報</td>
          <td>失敗、看不出根因</td>
      </tr>
      <tr>
          <td>2</td>
          <td>改 CSS + 使用者截圖回報</td>
          <td>失敗、累積錯誤假設</td>
      </tr>
      <tr>
          <td>3</td>
          <td>加更多覆寫 + 使用者截圖回報</td>
          <td>失敗、使用者「思路錯了」</td>
      </tr>
      <tr>
          <td>4</td>
          <td>「我啟個 server 看看」</td>
          <td>立刻發現 drawer 在 form 內</td>
      </tr>
  </tbody>
</table>
<p>第 4 輪用 playwright <code>browser_evaluate</code> 讀 ancestor chain — 一個 query、一個答案、兩分鐘解。前三輪 ≈ 30 分鐘。</p>
<h3 id="判讀">判讀</h3>
<p>第 2 輪失敗時就應該主動提：</p>
<blockquote>
<p>「我嘗試了兩次都失敗、根因可能在我對 DOM 結構的假設。要不要啟個 server、我用 playwright 直接讀 live DOM 確認？這樣比繼續用截圖溝通快。」</p></blockquote>
<p>使用者啟 server、我跑 query、一輪解。</p>
<h3 id="執行主動提工具的-protocol">執行：主動提工具的 protocol</h3>
<p>驗證工具該在這些時機主動提：</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該提的工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理連續失敗 ≥ 2 次</td>
          <td>playwright <code>browser_evaluate</code> 讀 live DOM</td>
      </tr>
      <tr>
          <td>不確定元素的真實位置</td>
          <td><code>getBoundingClientRect</code></td>
      </tr>
      <tr>
          <td>不確定 computed style 套到什麼值</td>
          <td><code>getComputedStyle</code></td>
      </tr>
      <tr>
          <td>不確定 framework 渲染後的 DOM</td>
          <td>playwright snapshot</td>
      </tr>
      <tr>
          <td>不確定跨 viewport 行為</td>
          <td>playwright 切換 viewport 重測</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="工具引入的成本與價值">工具引入的成本與價值</h2>
<h3 id="內在屬性比較">內在屬性比較</h3>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>起步成本</th>
          <th>每輪成本</th>
          <th>涵蓋</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 + 截圖</td>
          <td>0</td>
          <td>高 — 截圖、描述、再試</td>
          <td>有限 — 看截圖看不到 DOM</td>
      </tr>
      <tr>
          <td>瀏覽器 DevTools 手動查</td>
          <td>0</td>
          <td>中 — 切面板、讀</td>
          <td>中 — 互動成本高</td>
      </tr>
      <tr>
          <td>Playwright <code>browser_evaluate</code></td>
          <td>中 — 起 server</td>
          <td>低 — 寫一段 evaluate</td>
          <td>高 — 任意 JS query</td>
      </tr>
      <tr>
          <td>Playwright 寫成測試</td>
          <td>中 — 起 server + 寫測試</td>
          <td>0 — 自動跑</td>
          <td>高 + 持續</td>
      </tr>
  </tbody>
</table>
<p>「起步成本」是一次性、「每輪成本」是重複的。第 2 輪以後、playwright 的 ROI 已經正向。</p>
<hr>
<h2 id="主動提的具體話術">主動提的具體話術</h2>
<h3 id="較差的提法">較差的提法</h3>
<blockquote>
<p>「要不要試試 playwright」</p></blockquote>
<p>模糊、使用者不一定知道為什麼要試、可能答「先這樣吧」。</p>
<h3 id="較好的提法">較好的提法</h3>
<blockquote>
<p>「我嘗試了兩次都失敗、根因可能不在 CSS、在我對 DOM 結構的假設。
要不要啟個 server（<code>python3 -m http.server 8000</code> 在 public/）、
我用 playwright <code>browser_evaluate</code> 直接讀 ancestor chain 確認？
這樣比繼續用截圖快很多。」</p></blockquote>
<p>說明：</p>
<ul>
<li><strong>為什麼提</strong>：兩次失敗、推理迴圈成本超過工具迴圈</li>
<li><strong>要使用者做什麼</strong>：啟 server、給一行指令</li>
<li><strong>我會做什麼</strong>：用 playwright evaluate 讀</li>
<li><strong>預期收益</strong>：縮短迴圈</li>
</ul>
<p>使用者明確知道 trade-off、決定簡單。</p>
<hr>
<h2 id="工具迴圈的標準流程">工具迴圈的標準流程</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln">1</span><span class="cl">1. 使用者啟 server（python3 -m http.server / hugo server）
</span></span><span class="line"><span class="ln">2</span><span class="cl">2. 執行者 navigate 到目標頁面
</span></span><span class="line"><span class="ln">3</span><span class="cl">3. 執行者寫 evaluate fn 讀真實狀態
</span></span><span class="line"><span class="ln">4</span><span class="cl">4. 執行者根據結果定位根因
</span></span><span class="line"><span class="ln">5</span><span class="cl">5. 執行者改 CSS / JS
</span></span><span class="line"><span class="ln">6</span><span class="cl">6. 執行者再 evaluate 驗證修復
</span></span><span class="line"><span class="ln">7</span><span class="cl">7. 使用者目視最後確認（可選）</span></span></code></pre></div><p>整個流程多數步驟在執行者這邊、使用者只在頭尾參與 — 對使用者負擔輕。</p>
<hr>
<h2 id="設計取捨驗證工具引入的時機">設計取捨：驗證工具引入的時機</h2>
<p>四種做法、各自機會成本不同。這個專案選 A（推理 ≥ 2 次失敗主動提）當預設、其他做法在特定情境合理。</p>
<blockquote>
<p>本篇是 <a href="../two-occurrence-threshold/">#42 2 次門檻</a> 抽象原則在「驗證工具切換」這個面向的應用。</p></blockquote>
<h3 id="a推理--2-次失敗主動提工具切換這個專案的預設">A：推理 ≥ 2 次失敗主動提工具切換（這個專案的預設）</h3>
<ul>
<li><strong>機制</strong>：靜態推理連續失敗 2 次、立刻提「啟個 server、我用 playwright 看 live DOM」+ 附啟用步驟與預期收益</li>
<li><strong>選 A 的理由</strong>：對使用者透明（看到 trade-off）、縮短診斷迴圈</li>
<li><strong>適合</strong>：CSS / DOM 行為跟預期不符的除錯</li>
<li><strong>代價</strong>：執行者要主動辨識「推理迴圈成本」與「工具迴圈成本」的交叉點</li>
</ul>
<h3 id="b等使用者要求才用工具">B：等使用者要求才用工具</h3>
<ul>
<li><strong>機制</strong>：執行者繼續推理、使用者覺得太慢時提</li>
<li><strong>跟 A 的取捨</strong>：B 對使用者更被動、A 主動；B 在使用者不知道有 playwright 選項時、會一直繼續</li>
<li><strong>B 才合理的情境</strong>：使用者明確表達「想用推理練習」、把工具切換當成放棄</li>
</ul>
<h3 id="c全程靜態推理不用工具">C：全程靜態推理、不用工具</h3>
<ul>
<li><strong>機制</strong>：堅持推理到底</li>
<li><strong>C 是反模式</strong>：推理迴圈成本累積、最後可能需要 4-5 輪才解決</li>
<li><strong>看起來吸引人的原因</strong>：覺得用工具是「能力不足」、想撐到自己想出來</li>
<li><strong>實際發生的代價</strong>：時間成本指數放大（每輪推理基於前輪錯假設）、最後還是要切工具</li>
</ul>
<h3 id="d一開始就用-playwright不嘗試推理">D：一開始就用 playwright、不嘗試推理</h3>
<ul>
<li><strong>機制</strong>：跳過推理、直接用工具量</li>
<li><strong>跟 A 的取捨</strong>：D 跳過推理階段省去 2 次嘗試、但前期 setup 成本投入比例較高（簡單問題不值得）</li>
<li><strong>D 比 A 好的情境</strong>：問題明確需要 live DOM 才能診斷（例如「framework 渲染後的結構」）— 推理本來就無法解</li>
</ul>
<hr>
<h2 id="判讀徵兆">判讀徵兆</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>應該主動提的工具</th>
          <th>提的話術重點</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 + 截圖溝通 ≥ 2 輪</td>
          <td>playwright <code>browser_evaluate</code></td>
          <td>我假設可能錯、用工具讀 live DOM 確認</td>
      </tr>
      <tr>
          <td>修了 CSS 但使用者截圖看起來沒變</td>
          <td>playwright <code>getComputedStyle</code></td>
          <td>確認 CSS 真的套到、不是 cache 問題</td>
      </tr>
      <tr>
          <td>不確定哪個 viewport 下會有問題</td>
          <td>playwright 多 viewport 測</td>
          <td>一次跑多 viewport、找出哪個壞</td>
      </tr>
      <tr>
          <td>互動狀態下行為不一致</td>
          <td>playwright 模擬互動 + 量測</td>
          <td>自動操作、量結果</td>
      </tr>
      <tr>
          <td>修好了想固化規範</td>
          <td>playwright 寫測試</td>
          <td>把這次發現的契約寫成 expect、未來破壞會被抓</td>
      </tr>
  </tbody>
</table>
<p><strong>核心原則</strong>：工具引入時機是「推理迴圈成本超過工具迴圈成本」的點 — 大多在第 2 次推理失敗時。早一點提、雙方都省時間。</p>
<p>跟 <a href="../verification-timeline-checkpoints/">#68 驗收的時間軸</a> 的關係：本卡是「debug 工具切換時機」、#68 是「驗收動作分散在四個時點」 — 兩者共用「動作該分配到哪個時點才有 ROI」這個結構。本卡的「第 2 次推理失敗就切工具」≈ #68 的「ship 前要設計 E2E case」 — 都是「把高 ROI 的動作放在對的時點、不要延後」。</p>
]]></content:encoded></item><item><title>Frontend with Playwright — SKILL 入口</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/</guid><description>&lt;p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。&lt;/p>
&lt;p>協議的核心命題：&lt;strong>先讀真實狀態、再寫規則；先量再改、不要靠假設&lt;/strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。&lt;/p>
&lt;hr>
&lt;h2 id="core-pillars支柱">Core Pillars（支柱）&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;strong>Read Before Write&lt;/strong> 先讀真實狀態&lt;/td>
 &lt;td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>CSS-First, JS-Augment&lt;/strong> CSS 為主、JS 補強&lt;/td>
 &lt;td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>&lt;strong>Measure, Don&amp;rsquo;t Assume&lt;/strong> 量測、不要假設&lt;/td>
 &lt;td>Layout / 行為 / 互動三層、用 playwright &lt;code>browser_evaluate&lt;/code> 把假設變已知&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="principles原則速查">Principles（原則速查）&lt;/h2>
&lt;p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。&lt;/p>
&lt;h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology&lt;/h3>
&lt;p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright &lt;code>browser_evaluate&lt;/code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。&lt;/p>
&lt;p>Selector 設計三維度：&lt;strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）&lt;/strong>。預設用最精準的、有證據再放寬。&lt;/p>
&lt;h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定&lt;/h3>
&lt;p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。&lt;strong>必須 runtime 才能知道的值&lt;/strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。&lt;/p>
&lt;p>JS 的職責是 &lt;strong>toggle class / 寫 var&lt;/strong>、不是設 inline style。&lt;code>!important&lt;/code> / inline &lt;code>display: none&lt;/code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 &lt;code>@layer&lt;/code> 包起來、自家 unlayered 自動贏 specificity。&lt;/p>
&lt;h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置&lt;/h3>
&lt;p>&lt;strong>位置 1：假設驗證&lt;/strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
&lt;strong>位置 2：行為驗證&lt;/strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
&lt;strong>位置 3：互動驗證&lt;/strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。&lt;/p></description><content:encoded><![CDATA[<p>框架無關的前端開發協議 + Playwright 驗證。原則適用於 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。</p>
<p>協議的核心命題：<strong>先讀真實狀態、再寫規則；先量再改、不要靠假設</strong>。前端 bug 多半來自「寫 CSS 時假設的 DOM 結構與實際不符」、「JS 改完元素被 framework 還原」、「listener 觸發頻率失控」。Playwright 把這些假設變成可驗證的量測值。</p>
<hr>
<h2 id="core-pillars支柱">Core Pillars（支柱）</h2>
<table>
  <thead>
      <tr>
          <th>支柱</th>
          <th>意義</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>Read Before Write</strong> 先讀真實狀態</td>
          <td>寫 CSS 前用 playwright/DevTools 量真實 DOM；寫 JS 前確認 framework 邊界</td>
      </tr>
      <tr>
          <td><strong>CSS-First, JS-Augment</strong> CSS 為主、JS 補強</td>
          <td>能 build-time 算的進 CSS、必須 runtime 量測的進 JS、邊界清楚不混搭</td>
      </tr>
      <tr>
          <td><strong>Measure, Don&rsquo;t Assume</strong> 量測、不要假設</td>
          <td>Layout / 行為 / 互動三層、用 playwright <code>browser_evaluate</code> 把假設變已知</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="principles原則速查">Principles（原則速查）</h2>
<p>讀者在本區塊能完成大方向判斷；具體展開（步驟 / 範例）依下方「觸發路由」進對應 reference。</p>
<h3 id="1-寫-css-前先確認-dom-topology">1. 寫 CSS 前先確認 DOM topology</h3>
<p>Class name 是約定、不是結構保證。寫 CSS 規則之前、用 playwright <code>browser_evaluate</code> 讀目標元素的 ancestor chain — 確認它在 DOM tree 的哪個位置、parent / sibling / 共用的 grid cell 是什麼。</p>
<p>Selector 設計三維度：<strong>起點（document / 元件根 / 函式參數 / closest）+ 範圍（直接子節點 / 子孫）+ 過濾（attribute / 已處理標記）</strong>。預設用最精準的、有證據再放寬。</p>
<h3 id="2-css--js-的邊界由值能否-build-time-定下來決定">2. CSS / JS 的邊界由「值能否 build-time 定下來」決定</h3>
<p>能在 build time 算出來的值（design token、固定 breakpoint、靜態尺寸）→ 寫進 CSS variable / static rule。<strong>必須 runtime 才能知道的值</strong>（form 高度、scroll 位置、container 寬度）→ JS 量測後寫回 CSS variable、CSS 仍然只讀變數。</p>
<p>JS 的職責是 <strong>toggle class / 寫 var</strong>、不是設 inline style。<code>!important</code> / inline <code>display: none</code> 是 anti-pattern — 改用 class toggle 把樣式留在 CSS。Vendor CSS 用 <code>@layer</code> 包起來、自家 unlayered 自動贏 specificity。</p>
<h3 id="3-playwright-在開發循環的三個位置">3. Playwright 在開發循環的三個位置</h3>
<p><strong>位置 1：假設驗證</strong>（寫 CSS 前）— 讀 ancestor chain、確認結構符合假設。
<strong>位置 2：行為驗證</strong>（規則寫完後）— 讀 bounding rect / computed style、確認 layout 結果。
<strong>位置 3：互動驗證</strong>（dispatch event 後讀 state）— 模擬 input / click、量化驗證互動結果。</p>
<p>第 2 次同個版型 bug → 把 query 寫成 playwright 測試固化、CI 防回歸。</p>
<h3 id="4-與-framework-managed-dom-共處的邊界辨識">4. 與 framework-managed DOM 共處的邊界辨識</h3>
<p>把 framework 子樹當「禁區」、客製 UI 注入到 framework 邊界外、用 CSS 控制視覺位置（absolute / margin / grid）。框架重渲染時、邊界外的客製 UI 不被 reconcile 清掉。</p>
<p><strong>JS 操作的邊界穩定性</strong>（從穩到不穩）：reparent 整節點 &gt; 改 inline style &gt; 改 attribute &gt; 改 textContent &gt; 改 innerHTML &gt; 改 framework 子節點。穩定性低的需要 MutationObserver 重做、或乾脆別碰。</p>
<p><strong>外部組件客製的合作層次</strong>（穩定性梯度）：CSS variable / API &gt; class hook &gt; boundary DOM &gt; 內部結構。離公共介面越近、升級越穩。</p>
<h3 id="5-reactive-監聽器的頻率盤點">5. Reactive 監聽器的頻率盤點</h3>
<p>MutationObserver 三維度：<strong>root（最窄）、options（最少）、debounce（最長可接受）</strong>。預設 <code>observer.observe(scope, { childList: true })</code>、不寫 <code>subtree: true</code> 除非有 case。</p>
<p>Polling（<code>setTimeout</code> / <code>setInterval</code>）有事件可監聽就替換成 MutationObserver — 0 latency / 0 idle CPU。Reactive perf debug 從 <code>console.count(callbackName)</code> 起、確認觸發頻率符合預期。</p>
<p>效能風險點四面向：<strong>iteration 成本（500 results × regex test）、reflow 成本（&gt;16ms 觸發 jank）、listener 頻率（如上）、resource 載入時序（lazy chunk vs critical path）</strong>。</p>
<h3 id="6-a11y-三道防線">6. A11y 三道防線</h3>
<p><strong>鍵盤可達性</strong>：visible focus indicator、邏輯 tab 順序、modal 有 escape 路徑。三者缺一不可。
<strong>動態 a11y</strong>：JS reparent / hide 時保存並還原 focus；變動內容用 <code>aria-live=&quot;polite&quot;</code> 廣播給 screen reader。
<strong>Native &gt; ARIA</strong>：能用 <code>&lt;button&gt;</code> / <code>&lt;fieldset&gt;</code> / <code>&lt;dialog&gt;</code> 就不要自己組 ARIA role — native HTML 自帶 keyboard / focus / a11y tree、ARIA 是補強不是替代。</p>
<hr>
<h2 id="when-to-consult-this-skill觸發路由">When to Consult This Skill（觸發路由）</h2>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>讀哪份 reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><code>references/dom-topology-first.md</code></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><code>references/css-js-boundary.md</code></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><code>references/playwright-in-loop.md</code></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><code>references/framework-coexistence.md</code></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><code>references/reactive-performance.md</code></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><code>references/accessibility-and-focus.md</code></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><code>references/data-flow-and-filter-composition.md</code></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><code>references/data-flow-and-filter-composition.md</code>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：以該情境為核心、把六大原則翻譯成可直接套用的協議步驟與範例。閱讀任一 reference 不需要回來看其他 reference。</p>
<hr>
<h2 id="success-criteriam1-m2-認知負擔類">Success Criteria（M1-M2 認知負擔類）</h2>
<table>
  <thead>
      <tr>
          <th>Metric</th>
          <th>定義</th>
          <th>目標</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><strong>M1</strong></td>
          <td>從 SKILL.md 出發、解決一個觸發情境需要開幾個檔案</td>
          <td>≤ 2</td>
      </tr>
      <tr>
          <td><strong>M2</strong></td>
          <td>隨機抽一份 reference、不讀其他 reference 能否獨立套用</td>
          <td>100%</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="directory-index">Directory Index</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="ln"> 1</span><span class="cl">frontend-with-playwright/
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">├── SKILL.md                                    # 本檔：六大原則速查 + 觸發路由
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">└── references/
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">    ├── dom-topology-first.md                   # 情境 1：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 設計
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    ├── css-js-boundary.md                      # 情境 2：CSS-only vs JS-assisted、class toggle、layers、variable 單一位置、檔案拆分
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    ├── playwright-in-loop.md                   # 情境 3：playwright 三個位置（假設 / 行為 / 互動驗證）+ 寫成 layout test
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    ├── framework-coexistence.md                # 情境 4：custom UI 留 framework 邊界外、外部組件四層合作、JS 操作邊界辨識
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    ├── reactive-performance.md                 # 情境 5：observer scope、polling→observer、頻率盤點、iteration / regex / reflow
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    ├── accessibility-and-focus.md              # 情境 6：focus on DOM move、keyboard 三要素、aria-live、native HTML &gt; ARIA
</span></span><span class="line"><span class="ln">10</span><span class="cl">    └── data-flow-and-filter-composition.md     # 情境 7：Filter × Source 層錯位 + 五策略 + 跨領域（前端 / 後端 / 演算法 / DB）</span></span></code></pre></div><hr>
<h2 id="reading-order建議閱讀順序">Reading Order（建議閱讀順序）</h2>
<ol>
<li>第一次接觸 → 從本 SKILL.md 的「三大支柱 + 六大原則」讀起</li>
<li>進入實際情境 → 依觸發路由讀對應 reference（只讀一份）</li>
<li>想驗證自己有沒有套用對 → 用該 reference 結尾的 self-check checklist 自評</li>
</ol>
<hr>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><code>requirement-protocol</code> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<p>當情境是「不確定該怎麼跟使用者溝通」 → 讀 requirement-protocol。
當情境是「知道要做什麼、不確定前端該怎麼實作驗證」 → 讀本 skill。
兩個 skill 的 <code>playwright</code> 段落互補：<code>requirement-protocol/tool-switching-timing</code> 講「何時切」、本 skill 的 <code>playwright-in-loop</code> 講「切了之後具體寫什麼 query」。</p>
<p><code>requirement-protocol/clarifying-ambiguous-instructions</code> 的「類型 5：篩選類」跟本 skill 的 <code>data-flow-and-filter-composition</code> 互補：上層講「該怎麼澄清」、本層講「澄清完該怎麼實作」。</p>
<hr>
<h2 id="相關抽象層原則在-contentreport">相關抽象層原則（在 content/report/）</h2>
<p>本 skill 的協議建立在幾條抽象層原則上：</p>
<ul>
<li><a href="/blog/report/two-occurrence-threshold/" data-link-title="2 次門檻：第一次是運氣、第二次是訊號" data-link-desc="同一個問題出現第 2 次時、就該停下來把處理層級升一階 — 從推理升到量測、從手動驗證升到自動化、從同方向嘗試升到換思路。第 1 次失敗的資訊不足、第 2 次提供「重複出現」的證據、值得付出升級成本。本文是 #11 / #15 / #20 / #23 四篇實作的共同抽象。">#42 2 次門檻</a> — 第 1 次失敗是運氣、第 2 次是訊號（playwright 切換時機的根據）</li>
<li><a href="/blog/report/minimum-necessary-scope-is-sanity-defense/" data-link-title="最小必要範圍是 sanity 防線：保護行為可預測性" data-link-desc="縮 selector 範圍、observer 範圍、JS 操作範圍 — 不是為了效能、是為了讓行為可預測、不被未來變動打破。本文是 #13 / #14 / #29 三篇實作的共同抽象。">#43 最小必要範圍</a> — selector / observer / 操作邊界從窄起（DOM 設計、Reactive 效能的根據）</li>
<li><a href="/blog/report/single-source-of-truth/" data-link-title="Single Source of Truth：值的住址只能有一處" data-link-desc="同一個值（CSS token、視覺基準、runtime 量測）的權威來源只能有一個位置 — 多源時會分歧、會漏改、會讓讀者不知道哪個生效。本文是 #3 / #26 / #27 三篇實作的共同抽象。">#44 SSOT</a> — 值的住址只能一處（CSS 變數、量測一致性的根據）</li>
<li><a href="/blog/report/external-component-collaboration-layers/" data-link-title="跟外部組件合作的層次：離介面越近、合作越穩" data-link-desc="客製外部組件的穩定性與「離組件作者保證的對外介面多遠」成反比。每往內推一層、依賴前提增加、升級風險上升、可逆性下降。本文是 #1 / #5 / #19 / #24 四篇實作的共同抽象。">#45 外部組件合作四層</a> — 離公共介面越近越穩（framework 共處的根據）</li>
<li><a href="/blog/report/compose-feature-at-source-layer/" data-link-title="Feature 操作要跟 Source 同層合成" data-link-desc="Filter / sort / count / transform / search 是 stream 操作、必須跟 stream 的 materialization 同層或更上游合成。在下游做 = 操作 subset 不是 stream。本原則跨前端 UI、後端 API、演算法管線通用、不只是視覺層 vs 資料層。">#64 同層合成</a> — Stream 操作必須跟 materialization 同層（Filter × Source 的本質）</li>
<li><a href="/blog/report/ease-of-writing-vs-intent-alignment/" data-link-title="寫作便利度跟意圖對齊反相關" data-link-desc="寫程式時最容易寫出的版本、通常是離意圖最遠的版本。便利度建立在「現有上下文 / 已 materialize 資料 / 已存在 API」上、而意圖對齊需要找到正確的層、處理上游、跨抽象層 — 兩者方向相反。識別這個反相關 = 識別自己掉進「容易寫的陷阱」。">#67 寫作便利度跟意圖對齊反相關</a> — 容易寫的位置通常是錯位的位置（meta-principle、解釋為什麼層錯位 / 寬 selector / inline style 等便利寫法都會出問題）</li>
<li><a href="/blog/report/verification-timeline-checkpoints/" data-link-title="驗收的時間軸：四個 checkpoint" data-link-desc="驗收不是單一動作、是分散在四個時點（寫之前 / 開發中 / ship 前 / ship 後）的累積判斷。每個 checkpoint 能 catch 不同類型的失敗、成本不同。早期 checkpoint 抓越多、晚期 checkpoint 越輕鬆。實務上常常 collapse 成「寫的時候 &#43; ship 後出問題才修」、跳過寫之前 / ship 前。">#68 驗收的時間軸：四個 checkpoint</a> — Layout test 屬 Ship 前 checkpoint 的具體做法</li>
<li><a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a> — Playwright 測試的驗證協議：寫完測試 + 第一次跑就 GREEN 是警訊、要先在 buggy code 上看到 RED 才相信測試 catch 到該 catch 的東西</li>
<li><a href="/blog/report/url-as-state-container/" data-link-title="URL 是 stateful UI 的儲存層 — 哪些 state 該寫進 URL" data-link-desc="互動式 UI 的 state 散落在多層（in-memory / URL / localStorage / server / index）、每層有不同特性。可分享 / 可恢復 / 可導航的 state 該寫進 URL — 不寫進 = silent 把這些特性犧牲掉。本文展開「state 的儲存層選擇」協議與 URL 的具體位置。">#70 URL 是 stateful UI 的儲存層</a> — 互動式 UI 的可分享 / 可恢復 / 可導航 state 該寫進 URL（搜尋 / filter / tab / sort / pagination 都該檢視）</li>
<li><a href="/blog/report/tab-order-mental-model-alignment/" data-link-title="Tab Order = DOM Order = Mental Model 三者對齊" data-link-desc="Tab 順序由 DOM 順序決定（除非用 tabindex 強制覆寫）。三者該對齊：DOM 順序、tab 順序、使用者 mental model 的互動順序。三者不一致時、優先重排 DOM 而非用 tabindex — tabindex &gt; 0 是反模式（[#52]）。">#71 Tab Order = DOM Order = Mental Model 三者對齊</a> — DOM 順序預設 = tab 順序、不對齊時優先重排 DOM、tabindex &gt; 0 是反模式</li>
<li><a href="/blog/report/external-trigger-for-high-roi-work/" data-link-title="高 ROI 無外部觸發的工作會被結構性跳過" data-link-desc="工作有兩個獨立維度：ROI 高低 &#43; 是否有外部觸發。高 ROI &#43; 無觸發 = ROI 的承諾、拖延的現實。靠紀律不可行 — 結構性偏差需要結構性對策（外部觸發 / CI / hook / 排程 / pair）。本卡是 #67 便利反相關、#68 checkpoint 跳過、#69 RED 跳過的共同上位原則。">#72 高 ROI 無外部觸發的工作會被結構性跳過</a> — meta-原則：寫測試 / refactor / a11y review / Ship 前 case 設計都需要外部觸發（CI / pre-commit / PR template）、不是靠紀律</li>
<li><a href="/blog/report/search-engine-matching-mode-mismatch/" data-link-title="搜尋引擎的匹配模式跟使用者預期的對齊" data-link-desc="搜尋引擎的匹配模式（prefix / substring / fuzzy / semantic）各有不同。預設多半是 prefix（為了 index size）、但使用者被 Google 訓練成預期 substring。沒對齊 = silent 失敗：搜「pre」找不到 backpressure。本卡展開五種匹配模式、跟使用者意圖的對齊協議、五個合成策略。">#73 搜尋引擎的匹配模式跟使用者預期的對齊</a> — Search feature 的 capability 維度：prefix vs substring vs fuzzy vs semantic 各自取捨、預設多為 prefix（為 index size）、跟使用者預期不對齊 = silent 失敗</li>
<li><a href="/blog/report/decision-dialogue-dimensions/" data-link-title="決策對話的五個維度：保持完整選擇空間" data-link-desc="對話中的「決策」不是單一動作、是多維度選擇空間：呈現格式 / 策略疊加 / 批次邊界 / 時間軸 / 選項類型。預設多半 collapse 到最窄格（開放問 &#43; 單策略 &#43; 一次完成 &#43; 立刻決 &#43; 單選）、塞使用者進最少自由度的盒子。本卡是 #74-#78 的上層串連 — 五張卡各對應一個維度的鬆綁。">#79 決策對話的五維度</a> — 設計取捨呈現給使用者時的 meta-框架（呈現 / 策略疊加 / 批次 / 時間 / 選項類型）— 「設計取捨段落」常用的五策略表 + 推薦 + 「先 ship X、Y 下輪」就是這五維度的展現</li>
<li><a href="/blog/report/literal-interception-vs-behavioral-refinement/" data-link-title="字面攔截 vs 行為精煉：驗證手段跟錯誤層次的對齊" data-link-desc="驗證手段必須跟錯誤層次對齊：字面錯誤（typo / syntax / 缺欄位）用 hook / lint / CI 攔截；行為錯誤（思考偏差 / 判斷錯位 / collapse 反模式）用 multi-pass spiral 收斂。強行用 hook 蓋行為錯誤 = 給出 false confidence、反而比沒保護危險。本卡是 #72 結構性對策在「驗證粒度」維度的 ceiling — 不是所有錯誤都該被攔截。">#82 字面攔截 vs 行為精煉</a> — playwright 測試是字面驗證（input → output 比對）、抓不到「為什麼這個 selector 設計錯」這類行為錯誤、需要 multi-pass review 配合</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.4.0 — 接入 #79 決策對話五維度（對應 #74-#78 系列）；協助前端設計取捨段落的呈現格式對齊 user-facing 決策協議
<strong>Version</strong>: 0.3.0 — 接入 #69-#73：相關抽象層原則段補 Test-First (#69)、URL state (#70)、tab order (#71)、外部觸發 meta (#72)、search 匹配模式 (#73)
<strong>Version</strong>: 0.2.0 — 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（涵蓋 Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 領域範例）；description 補跨領域 stream 操作觸發詞；SKILL.md 加「相關抽象層原則」段（#42-45 + #64 + #67-68）；強調「不只前端、stream 操作通用」
<strong>Version</strong>: 0.1.0 — 從 <code>content/report/</code> 50+ 篇事後檢討萃取「前端網頁開發 + Playwright 驗證」這條主軸；六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</p>
]]></content:encoded></item><item><title>Frontend with Playwright — 框架無關的前端開發 + Playwright 驗證</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/</guid><description>&lt;h2 id="這個資料夾是什麼">這個資料夾是什麼&lt;/h2>
&lt;p>&lt;code>frontend-with-playwright&lt;/code> 是一套前端開發協議 skill，原生位置在 &lt;a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright">&lt;code>.claude/skills/frontend-with-playwright/&lt;/code>&lt;/a> 供 Claude runtime 呼叫；這份是&lt;strong>同內容的文章版本&lt;/strong>，讓人類讀者也能直接在 blog 閱讀。&lt;/p>
&lt;p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 &lt;a href="https://tarrragon.github.io/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。">&lt;code>content/report/&lt;/code>&lt;/a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。&lt;/p>
&lt;h2 id="閱讀順序">閱讀順序&lt;/h2>
&lt;h3 id="場景-1第一次接觸">場景 1：第一次接觸&lt;/h3>
&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>1&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &amp;#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md&lt;/a>&lt;/td>
 &lt;td>三大支柱 + 六大原則速查、觸發路由表&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>2&lt;/td>
 &lt;td>依情境挑一份 reference（見下表）&lt;/td>
 &lt;td>把原則翻譯成可套用的協議步驟、模板與範例&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>3&lt;/td>
 &lt;td>該 reference 結尾的 self-check checklist&lt;/td>
 &lt;td>自評有沒有按協議走&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務&lt;/h3>
&lt;p>直接依觸發情境跳對應 reference：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>觸發情境&lt;/th>
 &lt;th>reference&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 selector 該多寬、命中其他元素&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>用 &lt;code>!important&lt;/code> / inline style 解 specificity&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要用 playwright 驗證 layout / 假設 / 互動&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout bug 第 2 次出現、想寫成測試&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>客製 UI 被 framework 還原、不知道該注入到哪&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要客製外部組件（pagefind / vendor library）&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>使用者反映卡頓、CPU 100%、scroll lag、resize jank&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要設計 MutationObserver / event listener 範圍&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>要驗收鍵盤 / screen reader / motor / 視覺 a11y&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>JS reparent 後 focus 跑掉、aria-live 沒朗讀&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &amp;gt; ARIA、視覺 / motor a11y。">accessibility-and-focus&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>設計 filter / sort / count 操作、source 是分批 / streaming&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>「Load more 後畫面閃但內容沒變」的 silent 缺口&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（層錯位）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Backend / 演算法 / map-reduce 的 post-filter 漏項&lt;/td>
 &lt;td>&lt;a href="https://tarrragon.github.io/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &amp;#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition&lt;/a>（跨領域同結構）&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。&lt;/p></description><content:encoded><![CDATA[<h2 id="這個資料夾是什麼">這個資料夾是什麼</h2>
<p><code>frontend-with-playwright</code> 是一套前端開發協議 skill，原生位置在 <a href="https://github.com/tarrragon/blog/tree/main/.claude/skills/frontend-with-playwright"><code>.claude/skills/frontend-with-playwright/</code></a> 供 Claude runtime 呼叫；這份是<strong>同內容的文章版本</strong>，讓人類讀者也能直接在 blog 閱讀。</p>
<p>原則框架無關 — 適用 vanilla HTML/CSS/JS、Vue、React、jQuery — 因為核心是「DOM / CSS / JS 三者的本質行為」加上「Playwright 用 live DOM 量測驗證」、不依賴特定框架的渲染機制。源頭是 <a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a> 累積的 50+ 篇事後檢討、由本 skill 的六份 reference 萃取對應六個情境的協議步驟。</p>
<h2 id="閱讀順序">閱讀順序</h2>
<h3 id="場景-1第一次接觸">場景 1：第一次接觸</h3>
<table>
  <thead>
      <tr>
          <th>順序</th>
          <th>檔案</th>
          <th>目的</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>1</td>
          <td><a href="/blog/skills/frontend-with-playwright/skill/" data-link-title="Frontend with Playwright — SKILL 入口" data-link-desc="框架無關的前端開發 &#43; Playwright 驗證 SKILL 入口：三大支柱、六大原則速查、六份情境 reference 的觸發路由。">SKILL.md</a></td>
          <td>三大支柱 + 六大原則速查、觸發路由表</td>
      </tr>
      <tr>
          <td>2</td>
          <td>依情境挑一份 reference（見下表）</td>
          <td>把原則翻譯成可套用的協議步驟、模板與範例</td>
      </tr>
      <tr>
          <td>3</td>
          <td>該 reference 結尾的 self-check checklist</td>
          <td>自評有沒有按協議走</td>
      </tr>
  </tbody>
</table>
<h3 id="場景-2已熟悉協議想直接解決當前任務">場景 2：已熟悉協議、想直接解決當前任務</h3>
<p>直接依觸發情境跳對應 reference：</p>
<table>
  <thead>
      <tr>
          <th>觸發情境</th>
          <th>reference</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>要寫 CSS 規則、需要先確認 DOM 結構 / selector 該怎麼寫</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定 selector 該多寬、命中其他元素</td>
          <td><a href="/blog/skills/frontend-with-playwright/dom-topology-first/" data-link-title="DOM Topology First — 寫 CSS 前先確認 DOM 結構" data-link-desc="frontend-with-playwright reference：寫 CSS 前用 playwright/DevTools 量真實 DOM、selector 三維度設計、起點四選一、idempotency 兩選一。">dom-topology-first</a></td>
      </tr>
      <tr>
          <td>不確定值該寫進 CSS 還是 JS、CSS layers / variable / class toggle 取捨</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>用 <code>!important</code> / inline style 解 specificity</td>
          <td><a href="/blog/skills/frontend-with-playwright/css-js-boundary/" data-link-title="CSS / JS Boundary — CSS / JS 邊界與 specificity 處理" data-link-desc="frontend-with-playwright reference：CSS-only vs JS-assisted 判準、class toggle 取代 inline style、CSS layers 取代 specificity 戰、variable 單一定義位置、檔案拆分。">css-js-boundary</a></td>
      </tr>
      <tr>
          <td>要用 playwright 驗證 layout / 假設 / 互動</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>Layout bug 第 2 次出現、想寫成測試</td>
          <td><a href="/blog/skills/frontend-with-playwright/playwright-in-loop/" data-link-title="Playwright in the Development Loop — 開發循環的三個位置" data-link-desc="frontend-with-playwright reference：Playwright 三個位置（假設 / 行為 / 互動驗證）的 evaluate 範例、寫成 layout test 的時機與模板、最低門檻 setup。">playwright-in-loop</a></td>
      </tr>
      <tr>
          <td>客製 UI 被 framework 還原、不知道該注入到哪</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>要客製外部組件（pagefind / vendor library）</td>
          <td><a href="/blog/skills/frontend-with-playwright/framework-coexistence/" data-link-title="Framework Coexistence — 跟 framework-managed DOM 共處" data-link-desc="frontend-with-playwright reference：framework 邊界辨識、JS 操作四級安全度、客製 UI 注入到邊界外、外部組件四層合作（公共介面 → 邊界 → 邊界 DOM → 內部結構）。">framework-coexistence</a></td>
      </tr>
      <tr>
          <td>使用者反映卡頓、CPU 100%、scroll lag、resize jank</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要設計 MutationObserver / event listener 範圍</td>
          <td><a href="/blog/skills/frontend-with-playwright/reactive-performance/" data-link-title="Reactive Performance — Reactive 效能盤點與優化" data-link-desc="frontend-with-playwright reference：MutationObserver 三維度、polling → observer、iteration / regex 成本、layout reflow、resource 載入時序、reactive listener 盤點協議。">reactive-performance</a></td>
      </tr>
      <tr>
          <td>要驗收鍵盤 / screen reader / motor / 視覺 a11y</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>JS reparent 後 focus 跑掉、aria-live 沒朗讀</td>
          <td><a href="/blog/skills/frontend-with-playwright/accessibility-and-focus/" data-link-title="Accessibility and Focus — A11y 三道防線" data-link-desc="frontend-with-playwright reference：鍵盤可達性三要素、focus management on DOM move、aria-live 動態廣播、Native HTML &gt; ARIA、視覺 / motor a11y。">accessibility-and-focus</a></td>
      </tr>
      <tr>
          <td>設計 filter / sort / count 操作、source 是分批 / streaming</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a></td>
      </tr>
      <tr>
          <td>「Load more 後畫面閃但內容沒變」的 silent 缺口</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（層錯位）</td>
      </tr>
      <tr>
          <td>Backend / 演算法 / map-reduce 的 post-filter 漏項</td>
          <td><a href="/blog/skills/frontend-with-playwright/data-flow-and-filter-composition/" data-link-title="Data Flow and Filter Composition — Filter × Source 層錯位與五策略" data-link-desc="frontend-with-playwright reference：Filter / sort / count / transform stream 操作的層錯位識別 &#43; 五策略合成。原則跨前端 / 後端 / 演算法 / DB 通用、playwright 驗證模板。">data-flow-and-filter-composition</a>（跨領域同結構）</td>
      </tr>
  </tbody>
</table>
<p>每份 reference 自包含：讀任一份不需要回頭讀其他 reference。</p>
<h2 id="跟-requirement-protocol-的關係">跟 requirement-protocol 的關係</h2>
<p><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。">requirement-protocol</a> 是上層的「對話協議」（澄清需求、失敗轉折、覆寫成本、工具切換時機）；本 skill 是下層的「前端執行協議」（DOM / CSS / JS / Playwright 的具體做法）。</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>該讀哪個 skill</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>不確定該怎麼跟使用者溝通、需求模糊、失敗該怎麼轉折</td>
          <td>requirement-protocol</td>
      </tr>
      <tr>
          <td>知道要做什麼、不確定前端該怎麼實作驗證</td>
          <td>frontend-with-playwright（本 skill）</td>
      </tr>
  </tbody>
</table>
<p>兩個 skill 的 <code>playwright</code> 段落互補：requirement-protocol 講「何時切」、本 skill 講「切了之後具體寫什麼 query」。</p>
<h2 id="與-blog-專案其他資料的關係">與 blog 專案其他資料的關係</h2>
<table>
  <thead>
      <tr>
          <th>位置</th>
          <th>角色</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>.claude/skills/frontend-with-playwright/</code></td>
          <td>實際 skill — Claude runtime 呼叫的檔案來源</td>
      </tr>
      <tr>
          <td><code>content/skills/frontend-with-playwright/</code>（本處）</td>
          <td>文章版本 — 人類讀者在 blog 閱讀</td>
      </tr>
      <tr>
          <td><a href="/blog/report/" data-link-title="Report — 開發過程的事後檢討" data-link-desc="blog 開發過程中、把實際遇到的版型 / 整合 / 框架共處等情境、整理成『應該怎麼做、沒這樣做會有什麼麻煩』的事後檢討。每篇皆為正向指引、幫助下一輪同類任務跳過反覆試錯。"><code>content/report/</code></a></td>
          <td>50+ 篇事後檢討、本 skill 的素材來源；reference 結尾連回對應篇</td>
      </tr>
      <tr>
          <td><a href="/blog/skills/requirement-protocol/" data-link-title="Requirement Protocol — 需求確認到實作的對話協議" data-link-desc="從需求確認到實作的對話協議：模糊指令澄清、可決定 vs 該確認、失敗 2 次轉折、覆寫成本告知、revert checkpoint、漸進驗證、工具切換時機。六大原則 &#43; 五份情境 reference。"><code>content/skills/requirement-protocol/</code></a></td>
          <td>上層對話協議 skill</td>
      </tr>
  </tbody>
</table>
<h2 id="last-updated">Last Updated</h2>
<p>2026-04-26 — v0.2.0 接入 #55-#68 系列：新增第 7 份 reference <code>data-flow-and-filter-composition</code>（Filter × Source 層錯位 + 五策略 + 跨前端 / 後端 / 演算法 / DB 範例）；強調原則跨領域通用、不只前端。</p>
<p>歷史版本：</p>
<ul>
<li>2026-04-26 — v0.1.0 初版：六份 references 對應「DOM topology / CSS-JS 邊界 / Playwright 三位置 / framework 共處 / Reactive 效能 / A11y」六個情境</li>
</ul>
]]></content:encoded></item><item><title>Playwright in the Development Loop — 開發循環的三個位置</title><link>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/frontend-with-playwright/playwright-in-loop/</guid><description>&lt;p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。&lt;/p>
&lt;p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。&lt;/p>&lt;/blockquote>
&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>即將寫 CSS 規則、想先確認 DOM 結構&lt;/td>
 &lt;td>位置 1：假設驗證 — 量 ancestor chain&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>規則寫完、想確認實際 layout 對&lt;/td>
 &lt;td>位置 2：行為驗證 — 量 bounding rect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>想驗證使用者互動後的狀態（filter / search / click）&lt;/td>
 &lt;td>位置 3：互動驗證 — dispatch event&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同個 layout bug 第 2 次出現&lt;/td>
 &lt;td>寫 layout test、CI 防回歸&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>不確定 server 怎麼起 / 怎麼接 playwright&lt;/td>
 &lt;td>看下方「最低門檻 setup」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具&lt;/h2>
&lt;p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。&lt;/p>
&lt;p>Playwright &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — &lt;strong>把假設變成量測值&lt;/strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。&lt;/p>
&lt;hr>
&lt;h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）&lt;/h2>
&lt;h3 id="量-ancestor-chain">量 ancestor chain&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">el&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&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 class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="量子節點與-sibling">量子節點與 sibling&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">parent&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nb">Array&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">from&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">parent&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">children&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">map&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">c&lt;/span> &lt;span class="p">=&amp;gt;&lt;/span> &lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">c&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&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;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-js" data-lang="js">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">count&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">querySelectorAll&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">length&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">first&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.result&amp;#39;&lt;/span>&lt;span class="p">)&lt;/span>&lt;span class="o">?&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">outerHTML&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">slice&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">200&lt;/span>&lt;span class="p">),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。&lt;/p>
&lt;hr>
&lt;h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）&lt;/h2>
&lt;h3 id="量-bounding-rect">量 bounding rect&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="nx">form&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__form&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="nx">scope&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.scope&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="nx">results&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.results&amp;#39;&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">getBoundingClientRect&lt;/span>&lt;span class="p">(),&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">&lt;span class="p">})&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>返回 &lt;code>{x, y, width, height, top, right, bottom, left}&lt;/code> 的純物件、能直接 assert 順序與位置。&lt;/p>
&lt;h3 id="量-computed-style">量 computed style&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="ln"> 1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln"> 2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 3&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">cs&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 4&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 5&lt;/span>&lt;span class="cl"> &lt;span class="nx">display&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">display&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 6&lt;/span>&lt;span class="cl"> &lt;span class="nx">position&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">position&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 7&lt;/span>&lt;span class="cl"> &lt;span class="nx">gridRow&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">gridRow&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 8&lt;/span>&lt;span class="cl"> &lt;span class="nx">color&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">color&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln"> 9&lt;/span>&lt;span class="cl"> &lt;span class="nx">zIndex&lt;/span>&lt;span class="o">:&lt;/span> &lt;span class="nx">cs&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">zIndex&lt;/span>&lt;span class="p">,&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">10&lt;/span>&lt;span class="cl"> &lt;span class="p">};&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">11&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">el&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.target&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">&lt;span class="c1">&lt;/span> &lt;span class="k">return&lt;/span> &lt;span class="nx">getComputedStyle&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="nx">el&lt;/span>&lt;span class="p">).&lt;/span>&lt;span class="nx">cssText&lt;/span>&lt;span class="p">;&lt;/span> &lt;span class="c1">// 全部 computed properties
&lt;/span>&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&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>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。&lt;/p></description><content:encoded><![CDATA[<p>Playwright 在前端開發循環的三個位置：假設驗證（寫 CSS 前）、行為驗證（規則寫完後）、互動驗證（dispatch event 後）。第 2 次同個版型 bug 出現 → 寫成測試固化。</p>
<p>適用：CSS / DOM debug、layout 驗收、互動行為驗證、寫 layout regression test。
不適用：純 unit test（function input/output、無 DOM）— 那用 Vitest / Jest 即可。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋三個位置的具體 query 範例、layout test 模板、最低門檻 setup。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>即將寫 CSS 規則、想先確認 DOM 結構</td>
          <td>位置 1：假設驗證 — 量 ancestor chain</td>
      </tr>
      <tr>
          <td>規則寫完、想確認實際 layout 對</td>
          <td>位置 2：行為驗證 — 量 bounding rect</td>
      </tr>
      <tr>
          <td>想驗證使用者互動後的狀態（filter / search / click）</td>
          <td>位置 3：互動驗證 — dispatch event</td>
      </tr>
      <tr>
          <td>同個 layout bug 第 2 次出現</td>
          <td>寫 layout test、CI 防回歸</td>
      </tr>
      <tr>
          <td>不確定 server 怎麼起 / 怎麼接 playwright</td>
          <td>看下方「最低門檻 setup」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼-playwright-是前端開發的核心驗證工具">為什麼 playwright 是前端開發的核心驗證工具</h2>
<p>CSS / DOM 的真實狀態 = 規則 + DOM tree + 樣式繼承 + 框架渲染的合成結果。靜態推理只能基於假設、視覺截圖只能傳達結果不傳達原因。</p>
<p>Playwright <code>browser_evaluate</code> 直接執行 JS 在 live page、返回 DOM tree / computed style / bounding rect — <strong>把假設變成量測值</strong>。寫一個 evaluate fn ≈ 30 行 JS，比反覆推理快得多。</p>
<hr>
<h2 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）</h2>
<h3 id="量-ancestor-chain">量 ancestor chain</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="量子節點與-sibling">量子節點與 sibling</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">parent</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">parent</span><span class="p">.</span><span class="nx">children</span><span class="p">).</span><span class="nx">map</span><span class="p">(</span><span class="nx">c</span> <span class="p">=&gt;</span> <span class="sb">`</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">c</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="量元素是否存在--數量">量元素是否存在 / 數量</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">count</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">).</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">first</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">outerHTML</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">200</span><span class="p">),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>寫 CSS 規則前 30 秒能省掉後續 30 分鐘推理。</p>
<hr>
<h2 id="位置-2行為驗證規則寫完後">位置 2：行為驗證（規則寫完後）</h2>
<h3 id="量-bounding-rect">量 bounding rect</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">form</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">scope</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">results</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><p>返回 <code>{x, y, width, height, top, right, bottom, left}</code> 的純物件、能直接 assert 順序與位置。</p>
<h3 id="量-computed-style">量 computed style</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">cs</span> <span class="o">=</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">    <span class="nx">display</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">display</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">    <span class="nx">position</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">position</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">    <span class="nx">gridRow</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">gridRow</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">    <span class="nx">color</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">color</span><span class="p">,</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">    <span class="nx">zIndex</span><span class="o">:</span> <span class="nx">cs</span><span class="p">.</span><span class="nx">zIndex</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="量實際贏的-css-rule">量「實際贏的 CSS rule」</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="c1">// CSSOM 沒提供標準 getMatchedCSSRules；用 computed style 加 inspect
</span></span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">cssText</span><span class="p">;</span>  <span class="c1">// 全部 computed properties
</span></span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="c1"></span><span class="p">}</span></span></span></code></pre></div><p>或在 DevTools Computed panel 看 — 但 playwright 能寫成測試重跑。</p>
<hr>
<h2 id="位置-3互動驗證dispatch-event-後讀-state">位置 3：互動驗證（dispatch event 後讀 state）</h2>
<h3 id="模擬-input">模擬 input</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>  <span class="c1">// 等 debounce / async render
</span></span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1"></span>  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="模擬-click">模擬 click</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-toggle button[data-scope=&#34;title&#34;]&#39;</span><span class="p">).</span><span class="nx">click</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">500</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">activeScope</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope-toggle [aria-pressed=&#34;true&#34;]&#39;</span><span class="p">)</span><span class="o">?</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">scope</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">visibleResults</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result:not([hidden])&#39;</span><span class="p">).</span><span class="nx">length</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><h3 id="模擬-viewport-resize透過-playwright-api不在-evaluate-內">模擬 viewport resize（透過 playwright API、不在 evaluate 內）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">375</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">667</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="kr">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">evaluate</span><span class="p">(()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">layout</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.layout&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">sidebarVisible</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}));</span></span></span></code></pre></div><hr>
<h2 id="第-2-次同個-bug--寫成-layout-測試固化">第 2 次同個 bug → 寫成 layout 測試固化</h2>
<p>第 1 次 debug 完成後、bug 修好。第 2 次同個版型問題（不同 commit / viewport / 內容狀態）再出現 → <strong>debug 完後把 query 寫成 playwright 測試</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">test</span><span class="p">,</span> <span class="nx">expect</span> <span class="p">}</span> <span class="nx">from</span> <span class="s1">&#39;@playwright/test&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">
</span></span><span class="line"><span class="ln"> 3</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;search scope is between form and results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">waitForSelector</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">
</span></span><span class="line"><span class="ln"> 7</span><span class="cl">  <span class="kr">const</span> <span class="nx">formRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">  <span class="kr">const</span> <span class="nx">scopeRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln"> 9</span><span class="cl">  <span class="kr">const</span> <span class="nx">resultsRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">
</span></span><span class="line"><span class="ln">11</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">formRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">formRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">resultsRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">scopeRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">14</span><span class="cl">
</span></span><span class="line"><span class="ln">15</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;sidebar visible at 1400px+&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">16</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1400</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">17</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">18</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeVisible</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">19</span><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="ln">20</span><span class="cl">
</span></span><span class="line"><span class="ln">21</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;sidebar hidden at &lt; 1400px&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">22</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">1399</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">23</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">24</span><span class="cl">  <span class="kr">await</span> <span class="nx">expect</span><span class="p">(</span><span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.sidebar&#39;</span><span class="p">)).</span><span class="nx">toBeHidden</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">25</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>未來 layout 改動觸發 regression、CI 立刻發現。</p>
<hr>
<h2 id="寫-layout-test-的優先順序">寫 layout test 的優先順序</h2>
<p>不要每個 layout 都寫測試 — 寫測試的 ROI 條件：</p>
<table>
  <thead>
      <tr>
          <th>條件</th>
          <th>該寫測試嗎</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>Bug 第 1 次出現</td>
          <td>否（修了就好）</td>
      </tr>
      <tr>
          <td>Bug 第 2 次出現</td>
          <td><strong>是</strong>（防回歸）</td>
      </tr>
      <tr>
          <td>Layout 跟 viewport 強相關（breakpoint）</td>
          <td>是（容易壞）</td>
      </tr>
      <tr>
          <td>Layout 跟 framework 重渲染相關</td>
          <td>是（升級時需要驗證）</td>
      </tr>
      <tr>
          <td>純視覺風格（顏色 / 字型）</td>
          <td>否（用視覺 review 即可）</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="最低門檻-setup">最低門檻 setup</h2>
<h3 id="server">Server</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 任何方式起本地 server</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">hugo server                                       <span class="c1"># Hugo</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public    <span class="c1"># 純靜態</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">npm run dev                                        <span class="c1"># framework dev server</span></span></span></code></pre></div><h3 id="playwright-mcp給-claude-用">Playwright MCP（給 Claude 用）</h3>
<p>Claude 透過 MCP 提供的 tool：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
<li><code>browser_click(selector)</code> / <code>browser_type(selector, text)</code> — 互動</li>
</ul>
<h3 id="playwright-測試給-ci-用">Playwright 測試（給 CI 用）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl">npm i -D @playwright/test
</span></span><span class="line"><span class="ln">2</span><span class="cl">npx playwright install
</span></span><span class="line"><span class="ln">3</span><span class="cl">npx playwright test</span></span></code></pre></div><p><code>playwright.config.ts</code> 設 baseURL 指向 <code>http://localhost:1313</code>（Hugo 預設）或自訂 port。</p>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1css-不生效">範例 1：CSS 不生效</h3>
<p><strong>錯</strong>：靜態推理 + 截圖溝通 4 次失敗。</p>
<p><strong>對</strong>：第 2 次失敗 → 切 playwright：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1">// 1. 確認 ancestor chain
</span></span></span><span class="line"><span class="ln"> 2</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln"> 4</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">el</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span><span class="p">)</span> <span class="p">{</span> <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span> <span class="p">}</span>
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">  <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl"><span class="c1">// → 看到目標元素是 form 的 child、不是 .pagefind-ui 的直接 child
</span></span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">10</span><span class="cl"><span class="c1">// 2. 確認 computed style 誰贏
</span></span></span><span class="line"><span class="ln">11</span><span class="cl"><span class="c1"></span><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">color</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1">// → &#34;rgb(0,0,255)&#34; — vendor 的藍色贏了
</span></span></span><span class="line"><span class="ln">13</span><span class="cl"><span class="c1"></span>
</span></span><span class="line"><span class="ln">14</span><span class="cl"><span class="c1">// 3. 換方向：用 @layer 把 vendor 包起來
</span></span></span></code></pre></div><h3 id="範例-2layout-第-2-次出現一樣的-bug">範例 2：Layout 第 2 次出現一樣的 bug</h3>
<p><strong>錯</strong>：手動在不同 viewport 下視覺驗證、commit、過幾週又壞、又手動驗證。</p>
<p><strong>對</strong>：第 2 次出現後寫成測試：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;layout golden path: form → scope → results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="k">for</span> <span class="p">(</span><span class="kr">const</span> <span class="nx">width</span> <span class="k">of</span> <span class="p">[</span><span class="mi">375</span><span class="p">,</span> <span class="mi">768</span><span class="p">,</span> <span class="mi">1024</span><span class="p">,</span> <span class="mi">1400</span><span class="p">,</span> <span class="mi">1920</span><span class="p">])</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">800</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="kr">const</span> <span class="nx">form</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="kr">const</span> <span class="nx">scope</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="nx">expect</span><span class="p">(</span><span class="nx">scope</span><span class="p">.</span><span class="nx">y</span><span class="p">,</span> <span class="sb">`at width=</span><span class="si">${</span><span class="nx">width</span><span class="si">}</span><span class="sb">`</span><span class="p">).</span><span class="nx">toBeGreaterThanOrEqual</span><span class="p">(</span><span class="nx">form</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">form</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>未來改 CSS、CI 直接告訴你哪個 viewport 壞了。</p>
<hr>
<h2 id="red-green-順序先看到-red-才相信-green">RED-GREEN 順序：先看到 RED 才相信 GREEN</h2>
<p>寫完 playwright test 後、必須先在「buggy code」跑出 RED 才能相信「fixed code」的 GREEN。詳見 <a href="/blog/report/test-first-red-before-green/" data-link-title="Test-First：先看到 RED 才相信 GREEN" data-link-desc="一個只看過 GREEN 的測試是「未驗證的訊號」、不是「會抓回歸的測試」。必須先在「該失敗的版本」上看到 RED、再在「該通過的版本」上看到 GREEN — 兩次跑都對、才能相信測試真的 catch 到該 catch 的東西。跳過 RED 等於把驗收標準降到「跑得通」、漏掉「測試自己有沒有 bug」這層。">#69 Test-First：先看到 RED 才相信 GREEN</a>。</p>
<p>修 bug 的順序：</p>
<ol>
<li><strong>先寫測試 + 跑 → RED</strong>（在 buggy code 上 fail、證明測試會 catch + bug 真的存在）</li>
<li><strong>修 code</strong></li>
<li><strong>跑測試 → GREEN</strong>（證明修對了 + 測試會抓回歸）</li>
</ol>
<p>跳過 step 1 的 retrospective 補救（修完才補測試）：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln"> 1</span><span class="cl"><span class="c1"># Stash 修復、checkout 修前 commit</span>
</span></span><span class="line"><span class="ln"> 2</span><span class="cl">git stash <span class="o">&amp;&amp;</span> git checkout &lt;pre-fix-commit&gt;
</span></span><span class="line"><span class="ln"> 3</span><span class="cl">
</span></span><span class="line"><span class="ln"> 4</span><span class="cl"><span class="c1"># Cherry-pick 測試 commit、build、跑</span>
</span></span><span class="line"><span class="ln"> 5</span><span class="cl">git cherry-pick &lt;test-commit&gt;
</span></span><span class="line"><span class="ln"> 6</span><span class="cl">make site <span class="o">&amp;&amp;</span> npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln"> 7</span><span class="cl"><span class="c1"># 預期：RED</span>
</span></span><span class="line"><span class="ln"> 8</span><span class="cl">
</span></span><span class="line"><span class="ln"> 9</span><span class="cl"><span class="c1"># 切回修後版本</span>
</span></span><span class="line"><span class="ln">10</span><span class="cl">git checkout main <span class="o">&amp;&amp;</span> git stash pop
</span></span><span class="line"><span class="ln">11</span><span class="cl">npm <span class="nb">test</span>
</span></span><span class="line"><span class="ln">12</span><span class="cl"><span class="c1"># 預期：GREEN</span></span></span></code></pre></div><p>兩個訊號都看到 + 順序對、測試才被驗證。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>debug / 驗證 layout 時：</p>
<ul>
<li><input disabled="" type="checkbox"> 寫 CSS 規則前、有沒有用 playwright 量過 ancestor chain？</li>
<li><input disabled="" type="checkbox"> 規則寫完後、有沒有用 playwright 量過 bounding rect / computed style 確認？</li>
<li><input disabled="" type="checkbox"> 互動行為（filter / click）有沒有用 playwright 模擬 + 量化驗證？</li>
<li><input disabled="" type="checkbox"> 同個 layout bug 第 2 次出現時、有沒有寫成測試？</li>
<li><input disabled="" type="checkbox"> 推理失敗 ≥ 2 次時、有沒有主動切換到 playwright（不等到第 5 次）？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/playwright-early-in-loop/" data-link-title="在開發循環裡早一點用 playwright 看真實結果" data-link-desc="靜態 CSS 推理跟視覺截圖溝通有極限 — 當行為與預期不符 ≥ 2 次，stop 推理、改用 playwright browser_evaluate 直接讀 live DOM。本文說明工具引入時機。">playwright-early-in-loop</a> — 在開發循環裡早一點用 playwright 看真實結果</li>
<li><a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">layout-tests-with-playwright</a> — 用前端測試把排版問題自動化</li>
<li><a href="/blog/report/verification-method-timing/" data-link-title="驗證方法的選擇時機" data-link-desc="靜態 CSS 推理 ≥ 2 次失敗就主動提『啟個 server、用 playwright 看 live DOM 比較快』、不要繼續猜。本文展開驗證工具的引入時機。">verification-method-timing</a> — 驗證方法的選擇時機</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item><item><title>Tool Switching Timing — 推理 / DevTools / Playwright 的切換時機</title><link>https://tarrragon.github.io/blog/skills/requirement-protocol/tool-switching-timing/</link><pubDate>Sun, 26 Apr 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/skills/requirement-protocol/tool-switching-timing/</guid><description>&lt;p>何時從靜態推理切換到量測工具、何時從 DevTools 升級到 Playwright、何時把 debug 過程寫成測試。&lt;/p>
&lt;p>適用：CSS / DOM debug、layout 卡關、不確定該用哪個工具。
不適用：純邏輯 bug（這時 logging / debugger 比 layout 工具有用）。&lt;/p>
&lt;blockquote>
&lt;p>&lt;strong>自包含聲明&lt;/strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋四種工具的 ROI 對照、切換時機、最低門檻入口。&lt;/p>&lt;/blockquote>
&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>推理 ≥ 2 次失敗&lt;/td>
 &lt;td>切到 playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>視覺截圖溝通迴圈卡住、雙方對「哪裡不對」沒共識&lt;/td>
 &lt;td>切到 playwright + 量化資料（rect / style）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Layout 在某些狀態下錯、其他狀態下對&lt;/td>
 &lt;td>切到 playwright、量不同狀態的 bounding rect&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>改 CSS 不生效、specificity 看起來對&lt;/td>
 &lt;td>切到 playwright、量 computed style&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同一個版型 bug 第 2 次出現&lt;/td>
 &lt;td>切到「寫成 playwright 測試」固化&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一次性確認 DOM 結構、不會重複查&lt;/td>
 &lt;td>用 DevTools 即可、不需要起 server&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="為什麼工具切換要早不該等到推理徹底失敗">為什麼工具切換要早、不該等到推理徹底失敗&lt;/h2>
&lt;p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。&lt;strong>靜態推理只能基於假設的 DOM tree&lt;/strong> — 假設錯了、推理就錯。視覺截圖只能傳達「結果是什麼」、無法傳達「為什麼」。&lt;/p>
&lt;p>Playwright 的 &lt;code>browser_evaluate&lt;/code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — &lt;strong>把四個變數全部變成已知&lt;/strong>。&lt;/p>
&lt;p>&lt;strong>門檻在第 2 次&lt;/strong>：第 1 次推理快（假設正確時一次到位）；第 2 次推理失敗 → 假設可能錯 → 繼續推理會在錯誤假設上累積。Playwright 起步成本中、但後續穩定。&lt;/p>
&lt;hr>
&lt;h2 id="四種工具的-roi-對照">四種工具的 ROI 對照&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>方法&lt;/th>
 &lt;th>取得資訊量&lt;/th>
 &lt;th>起步成本&lt;/th>
 &lt;th>重複成本&lt;/th>
 &lt;th>可寫成測試&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>靜態 CSS 推理&lt;/td>
 &lt;td>低 — 全是假設&lt;/td>
 &lt;td>0&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;td>中&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>瀏覽器 DevTools&lt;/td>
 &lt;td>高 — DOM + computed&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>否&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;td>最高 — 程式化任意查詢&lt;/td>
 &lt;td>中&lt;/td>
 &lt;td>低&lt;/td>
 &lt;td>是 — 同樣 query 可寫測試&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>&lt;strong>選擇順序&lt;/strong>：&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>第 1 次推理（簡單修改、假設正確機率高）&lt;/td>
 &lt;td>靜態推理 + 截圖&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>一次性確認、不重複查&lt;/td>
 &lt;td>DevTools&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>推理 ≥ 2 次失敗 / 反覆 debug&lt;/td>
 &lt;td>Playwright &lt;code>browser_evaluate&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>同個版型 bug 第 2 次以上&lt;/td>
 &lt;td>Playwright 測試固化&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;hr>
&lt;h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置&lt;/h2>
&lt;h3 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）&lt;/h3>
&lt;p>確認 DOM 結構符合假設。&lt;/p>





&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="ln">1&lt;/span>&lt;span class="cl">&lt;span class="kr">async&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="ln">2&lt;/span>&lt;span class="cl"> &lt;span class="kr">const&lt;/span> &lt;span class="nx">drawer&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">querySelector&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;.pagefind-ui__drawer&amp;#39;&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl"> &lt;span class="kd">let&lt;/span> &lt;span class="nx">chain&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="p">[];&lt;/span> &lt;span class="kd">let&lt;/span> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">drawer&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl"> &lt;span class="k">while&lt;/span> &lt;span class="p">(&lt;/span>&lt;span class="nx">n&lt;/span> &lt;span class="o">&amp;amp;&amp;amp;&lt;/span> &lt;span class="nx">n&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 class="p">{&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl"> &lt;span class="nx">chain&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">push&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">tagName&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">.&lt;/span>&lt;span class="si">${&lt;/span>&lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">className&lt;/span>&lt;span class="si">}&lt;/span>&lt;span class="sb">`&lt;/span>&lt;span class="p">);&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">6&lt;/span>&lt;span class="cl"> &lt;span class="nx">n&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="nx">n&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="nx">parentElement&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">7&lt;/span>&lt;span class="cl"> &lt;span class="p">}&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">8&lt;/span>&lt;span class="cl"> &lt;span class="k">return&lt;/span> &lt;span class="nx">chain&lt;/span>&lt;span class="p">;&lt;/span>
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">9&lt;/span>&lt;span class="cl">&lt;span class="p">}&lt;/span>&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>返回值對照假設、發現 &lt;code>drawer&lt;/code> 是 &lt;code>form&lt;/code> 的 child（不是 sibling）→ grid-row 控制無效、改方向。&lt;/p></description><content:encoded><![CDATA[<p>何時從靜態推理切換到量測工具、何時從 DevTools 升級到 Playwright、何時把 debug 過程寫成測試。</p>
<p>適用：CSS / DOM debug、layout 卡關、不確定該用哪個工具。
不適用：純邏輯 bug（這時 logging / debugger 比 layout 工具有用）。</p>
<blockquote>
<p><strong>自包含聲明</strong>：閱讀本文件不需要先讀其他 reference。本文件涵蓋四種工具的 ROI 對照、切換時機、最低門檻入口。</p></blockquote>
<hr>
<h2 id="何時參閱本文件">何時參閱本文件</h2>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>該做的第一件事</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>推理 ≥ 2 次失敗</td>
          <td>切到 playwright <code>browser_evaluate</code></td>
      </tr>
      <tr>
          <td>視覺截圖溝通迴圈卡住、雙方對「哪裡不對」沒共識</td>
          <td>切到 playwright + 量化資料（rect / style）</td>
      </tr>
      <tr>
          <td>Layout 在某些狀態下錯、其他狀態下對</td>
          <td>切到 playwright、量不同狀態的 bounding rect</td>
      </tr>
      <tr>
          <td>改 CSS 不生效、specificity 看起來對</td>
          <td>切到 playwright、量 computed style</td>
      </tr>
      <tr>
          <td>同一個版型 bug 第 2 次出現</td>
          <td>切到「寫成 playwright 測試」固化</td>
      </tr>
      <tr>
          <td>一次性確認 DOM 結構、不會重複查</td>
          <td>用 DevTools 即可、不需要起 server</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="為什麼工具切換要早不該等到推理徹底失敗">為什麼工具切換要早、不該等到推理徹底失敗</h2>
<p>CSS 行為由「規則 + DOM tree + 樣式繼承 + 框架渲染」四個變數共同決定。<strong>靜態推理只能基於假設的 DOM tree</strong> — 假設錯了、推理就錯。視覺截圖只能傳達「結果是什麼」、無法傳達「為什麼」。</p>
<p>Playwright 的 <code>browser_evaluate</code> 直接執行 JS 在 live page、返回真實的 DOM tree、computed style、bounding rect — <strong>把四個變數全部變成已知</strong>。</p>
<p><strong>門檻在第 2 次</strong>：第 1 次推理快（假設正確時一次到位）；第 2 次推理失敗 → 假設可能錯 → 繼續推理會在錯誤假設上累積。Playwright 起步成本中、但後續穩定。</p>
<hr>
<h2 id="四種工具的-roi-對照">四種工具的 ROI 對照</h2>
<table>
  <thead>
      <tr>
          <th>方法</th>
          <th>取得資訊量</th>
          <th>起步成本</th>
          <th>重複成本</th>
          <th>可寫成測試</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>靜態 CSS 推理</td>
          <td>低 — 全是假設</td>
          <td>0</td>
          <td>高</td>
          <td>否</td>
      </tr>
      <tr>
          <td>視覺截圖溝通</td>
          <td>中 — 只有結果</td>
          <td>低</td>
          <td>中</td>
          <td>否</td>
      </tr>
      <tr>
          <td>瀏覽器 DevTools</td>
          <td>高 — DOM + computed</td>
          <td>低</td>
          <td>中</td>
          <td>否</td>
      </tr>
      <tr>
          <td>Playwright <code>browser_evaluate</code></td>
          <td>最高 — 程式化任意查詢</td>
          <td>中</td>
          <td>低</td>
          <td>是 — 同樣 query 可寫測試</td>
      </tr>
  </tbody>
</table>
<p><strong>選擇順序</strong>：</p>
<table>
  <thead>
      <tr>
          <th>情境</th>
          <th>工具</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>第 1 次推理（簡單修改、假設正確機率高）</td>
          <td>靜態推理 + 截圖</td>
      </tr>
      <tr>
          <td>一次性確認、不重複查</td>
          <td>DevTools</td>
      </tr>
      <tr>
          <td>推理 ≥ 2 次失敗 / 反覆 debug</td>
          <td>Playwright <code>browser_evaluate</code></td>
      </tr>
      <tr>
          <td>同個版型 bug 第 2 次以上</td>
          <td>Playwright 測試固化</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="playwright-在開發循環的三個位置">Playwright 在開發循環的三個位置</h2>
<h3 id="位置-1假設驗證寫-css-規則前">位置 1：假設驗證（寫 CSS 規則前）</h3>
<p>確認 DOM 結構符合假設。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">drawer</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__drawer&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">let</span> <span class="nx">chain</span> <span class="o">=</span> <span class="p">[];</span> <span class="kd">let</span> <span class="nx">n</span> <span class="o">=</span> <span class="nx">drawer</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="k">while</span> <span class="p">(</span><span class="nx">n</span> <span class="o">&amp;&amp;</span> <span class="nx">n</span> <span class="o">!==</span> <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">chain</span><span class="p">.</span><span class="nx">push</span><span class="p">(</span><span class="sb">`</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">tagName</span><span class="si">}</span><span class="sb">.</span><span class="si">${</span><span class="nx">n</span><span class="p">.</span><span class="nx">className</span><span class="si">}</span><span class="sb">`</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">n</span> <span class="o">=</span> <span class="nx">n</span><span class="p">.</span><span class="nx">parentElement</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">  <span class="k">return</span> <span class="nx">chain</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>返回值對照假設、發現 <code>drawer</code> 是 <code>form</code> 的 child（不是 sibling）→ grid-row 控制無效、改方向。</p>
<h3 id="位置-2行為驗證layout-規則寫完後">位置 2：行為驗證（layout 規則寫完後）</h3>
<p>驗證實際 layout 結果。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">({</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="nx">rect</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">computedTop</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">top</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">computedDisplay</span><span class="o">:</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">display</span><span class="p">,</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">})</span></span></span></code></pre></div><h3 id="位置-3互動驗證使用者操作後的狀態">位置 3：互動驗證（使用者操作後的狀態）</h3>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">input</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.search-input&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="s1">&#39;pre&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="nx">input</span><span class="p">.</span><span class="nx">dispatchEvent</span><span class="p">(</span><span class="k">new</span> <span class="nx">Event</span><span class="p">(</span><span class="s1">&#39;input&#39;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">bubbles</span><span class="o">:</span> <span class="kc">true</span> <span class="p">}));</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">await</span> <span class="k">new</span> <span class="nb">Promise</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">setTimeout</span><span class="p">(</span><span class="nx">r</span><span class="p">,</span> <span class="mi">1000</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelectorAll</span><span class="p">(</span><span class="s1">&#39;.result&#39;</span><span class="p">))</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">    <span class="p">.</span><span class="nx">filter</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nx">el</span><span class="p">).</span><span class="nx">display</span> <span class="o">!==</span> <span class="s1">&#39;none&#39;</span><span class="p">)</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">el</span> <span class="p">=&gt;</span> <span class="nx">el</span><span class="p">.</span><span class="nx">textContent</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">50</span><span class="p">));</span>
</span></span><span class="line"><span class="ln">9</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><hr>
<h2 id="第-2-次同個-bug--寫成測試固化">第 2 次同個 bug → 寫成測試固化</h2>
<p>第 1 次 debug 完、bug 修好。第 2 次同個版型問題（不同 commit / 不同 viewport）再出現 → <strong>debug 完後把 query 寫成 playwright 測試</strong>。</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="nx">test</span><span class="p">(</span><span class="s1">&#39;search scope is between form and results&#39;</span><span class="p">,</span> <span class="kr">async</span> <span class="p">({</span> <span class="nx">page</span> <span class="p">})</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="kr">goto</span><span class="p">(</span><span class="s1">&#39;/search/?q=pre&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kr">const</span> <span class="nx">formRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.pagefind-ui__form&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="kr">const</span> <span class="nx">scopeRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.scope-toggle&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">  <span class="kr">const</span> <span class="nx">resultsRect</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">locator</span><span class="p">(</span><span class="s1">&#39;.results&#39;</span><span class="p">).</span><span class="nx">boundingBox</span><span class="p">();</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">formRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">formRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="nx">expect</span><span class="p">(</span><span class="nx">resultsRect</span><span class="p">.</span><span class="nx">y</span><span class="p">).</span><span class="nx">toBeGreaterThan</span><span class="p">(</span><span class="nx">scopeRect</span><span class="p">.</span><span class="nx">y</span> <span class="o">+</span> <span class="nx">scopeRect</span><span class="p">.</span><span class="nx">height</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">});</span></span></span></code></pre></div><p>未來 layout 改動觸發 regression、CI 立刻發現、不需要再人工 debug。</p>
<hr>
<h2 id="playwright-引入的最低門檻">Playwright 引入的最低門檻</h2>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1"># 起本地 server（任何方式）</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">python3 -m http.server <span class="m">8000</span> --directory public
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="c1"># 或 hugo server</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">hugo server</span></span></code></pre></div><p>Playwright MCP 提供的核心工具：</p>
<ul>
<li><code>browser_navigate(url)</code> — 開頁</li>
<li><code>browser_evaluate(fn)</code> — 執行 JS 拿結果</li>
<li><code>browser_take_screenshot()</code> — 截圖</li>
<li><code>browser_snapshot()</code> — accessibility tree</li>
</ul>
<p>寫一個 evaluate fn ≈ 30 行 JS。比反覆推理快得多。</p>
<hr>
<h2 id="主動切換訊號不要等使用者打斷">主動切換訊號（不要等使用者打斷）</h2>
<p>當以下任一觸發、執行者要主動提：「我推理 2 次失敗了、我們起 server、用 playwright 量 live DOM 確認假設」。<strong>不要等到第 5 次才切</strong>。</p>
<table>
  <thead>
      <tr>
          <th>訊號</th>
          <th>對外回報句式</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>同方向 CSS 規則改了 2 次都不生效</td>
          <td>「我假設 X 是 Y、playwright 一查就知道、要起 server？」</td>
      </tr>
      <tr>
          <td>截圖看起來對 / 不對、但雙方對「為什麼」沒共識</td>
          <td>「用 playwright 量 bounding rect、量化比較好？」</td>
      </tr>
      <tr>
          <td>改完 JS 後元素被還原</td>
          <td>「playwright 量 framework 重渲染週期、確認時機」</td>
      </tr>
      <tr>
          <td>Layout 在某些 state 下錯、其他對</td>
          <td>「我用 playwright 各 state 量一次 rect、做對照」</td>
      </tr>
  </tbody>
</table>
<hr>
<h2 id="wrong-vs-right-對照">Wrong vs Right 對照</h2>
<h3 id="範例-1css-不生效">範例 1：CSS 不生效</h3>
<p><strong>錯</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="c">/* 改了 3 次 specificity、還是沒生效 */</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>                    <span class="c">/* 失敗 */</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span>            <span class="c">/* 失敗 */</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">container</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span> <span class="c">/* 失敗 */</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">.</span><span class="nc">parent</span> <span class="p">.</span><span class="nc">container</span> <span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span> <span class="cp">!important</span><span class="p">;</span> <span class="p">}</span> <span class="c">/* 失敗 */</span></span></span></code></pre></div><p><strong>對</strong>：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-css" data-lang="css"><span class="line"><span class="ln">1</span><span class="cl"><span class="p">.</span><span class="nc">target</span> <span class="p">{</span> <span class="k">color</span><span class="p">:</span> <span class="kc">red</span><span class="p">;</span> <span class="p">}</span></span></span></code></pre></div><p>第 2 次失敗 → 切 playwright：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="nx">getComputedStyle</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">)).</span><span class="nx">color</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1">// 返回 &#34;rgb(0, 0, 255)&#34; — 不是我寫的紅色
</span></span></span></code></pre></div>




<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">const</span> <span class="nx">el</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.target&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="nb">Array</span><span class="p">.</span><span class="nx">from</span><span class="p">(</span><span class="nx">getMatchedCSSRules</span><span class="o">?</span><span class="p">.(</span><span class="nx">el</span><span class="p">)</span> <span class="o">||</span> <span class="p">[])</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="p">.</span><span class="nx">map</span><span class="p">(</span><span class="nx">r</span> <span class="p">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nx">cssText</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl"><span class="p">}</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl"><span class="c1">// 看到 vendor 的 .pagefind .target { color: blue !important } 在贏
</span></span></span></code></pre></div><p>→ 換方向：用 CSS Layers 把 vendor CSS 包進 layer、自家 unlayered 自動贏。</p>
<h3 id="範例-2layout-在-mobile-viewport-錯">範例 2：Layout 在 mobile viewport 錯</h3>
<p><strong>錯</strong>：</p>
<p>反覆推理 + 在 DevTools 切 viewport 視覺確認 → 改 → 失敗 → 改 → 失敗。</p>
<p><strong>對</strong>：</p>
<p>第 2 次推理失敗、切 playwright：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-js" data-lang="js"><span class="line"><span class="ln">1</span><span class="cl"><span class="kr">async</span> <span class="p">()</span> <span class="p">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">2</span><span class="cl">  <span class="kr">await</span> <span class="nx">page</span><span class="p">.</span><span class="nx">setViewportSize</span><span class="p">({</span> <span class="nx">width</span><span class="o">:</span> <span class="mi">375</span><span class="p">,</span> <span class="nx">height</span><span class="o">:</span> <span class="mi">667</span> <span class="p">});</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="k">return</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">    <span class="nx">h1</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;h1&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">5</span><span class="cl">    <span class="nx">form</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;form&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">6</span><span class="cl">    <span class="nx">scope</span><span class="o">:</span> <span class="nb">document</span><span class="p">.</span><span class="nx">querySelector</span><span class="p">(</span><span class="s1">&#39;.scope&#39;</span><span class="p">).</span><span class="nx">getBoundingClientRect</span><span class="p">(),</span>
</span></span><span class="line"><span class="ln">7</span><span class="cl">  <span class="p">};</span>
</span></span><span class="line"><span class="ln">8</span><span class="cl"><span class="p">}</span></span></span></code></pre></div><p>量化資料 → 立刻看到「scope 的 top 比 form 的 bottom 小 12px」→ overlap → 改 form margin-bottom。</p>
<hr>
<h2 id="自檢清單dogfooding">自檢清單（dogfooding）</h2>
<p>debug 卡關時：</p>
<ul>
<li><input disabled="" type="checkbox"> 我推理失敗幾次了？≥ 2 次 → 該切換工具</li>
<li><input disabled="" type="checkbox"> 我能說出「假設是什麼、用什麼工具能驗證」嗎？</li>
<li><input disabled="" type="checkbox"> 切到 playwright 之前、有沒有試圖用更努力的推理多撐一次？（如果有 → 停）</li>
<li><input disabled="" type="checkbox"> 第 2 次同個版型 bug 出現時、有沒有寫成測試固化？</li>
<li><input disabled="" type="checkbox"> 對外回報切換工具的提案、有沒有寫得具體（要起哪個 server、量什麼）？</li>
</ul>
<hr>
<h2 id="延伸閱讀">延伸閱讀</h2>
<p>對應的事後檢討（在 <code>content/report/</code>）：</p>
<ul>
<li><a href="/blog/report/playwright-early-in-loop/" data-link-title="在開發循環裡早一點用 playwright 看真實結果" data-link-desc="靜態 CSS 推理跟視覺截圖溝通有極限 — 當行為與預期不符 ≥ 2 次，stop 推理、改用 playwright browser_evaluate 直接讀 live DOM。本文說明工具引入時機。">playwright-early-in-loop</a> — 在開發循環裡早一點用 playwright 看真實結果</li>
<li><a href="/blog/report/verification-method-timing/" data-link-title="驗證方法的選擇時機" data-link-desc="靜態 CSS 推理 ≥ 2 次失敗就主動提『啟個 server、用 playwright 看 live DOM 比較快』、不要繼續猜。本文展開驗證工具的引入時機。">verification-method-timing</a> — 驗證方法的選擇時機</li>
<li><a href="/blog/report/layout-tests-with-playwright/" data-link-title="用前端測試把排版問題自動化" data-link-desc="排版問題傳統靠人眼檢查、容易遺漏邊界 case。當一個版型被 debug 兩次以上、就值得寫成 playwright 測試把規範固定下來。本文展開測試替代手動檢查的時機。">layout-tests-with-playwright</a> — 用前端測試把排版問題自動化</li>
</ul>
<hr>
<p><strong>Last Updated</strong>: 2026-04-26
<strong>Version</strong>: 0.1.0</p>
]]></content:encoded></item></channel></rss>