<?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>開發測試案例庫 on Tarragon</title><link>https://tarrragon.github.io/blog/testing/cases/</link><description>Recent content in 開發測試案例庫 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/testing/cases/index.xml" rel="self" type="application/rss+xml"/><item><title>T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 &lt;code>sink.add(dynamic)&lt;/code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。&lt;code>ConnectionManager.sendData()&lt;/code> 接收 &lt;code>Uint8List&lt;/code> 型別的鍵盤輸入，直接傳給 &lt;code>_channel!.sink.add(data)&lt;/code>。Dart 的 &lt;code>IOWebSocketChannel&lt;/code> 對 &lt;code>Uint8List&lt;/code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。&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>影響範圍&lt;/td>
 &lt;td>所有鍵盤輸入無效（使用者打字無回應）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>192 個全過（&lt;code>FakeWebSocketChannel.sink.add&lt;/code> 不區分型別）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功但終端機完全無反應&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>&lt;code>if (data is Uint8List) sink.add(String.fromCharCodes(data))&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 忠實實作了 &lt;code>WebSocketChannel&lt;/code> 的 Dart interface — &lt;code>sink.add(dynamic)&lt;/code> 接受任何型別。但 &lt;code>IOWebSocketChannel&lt;/code> 的 &lt;code>sink.add&lt;/code> 實際行為是：&lt;code>String&lt;/code> → text frame，&lt;code>List&amp;lt;int&amp;gt;&lt;/code> / &lt;code>Uint8List&lt;/code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>ttyd 的靜默忽略放大了問題&lt;/strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>型別系統幫不上忙&lt;/strong>。Dart 的 &lt;code>WebSocketSink.add&lt;/code> 簽名是 &lt;code>void add(dynamic event)&lt;/code> — &lt;code>dynamic&lt;/code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 &lt;code>dynamic&lt;/code>，型別檢查無法區分協議語意。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test&lt;/strong>：對真實 ttyd 發送 &lt;code>Uint8List&lt;/code> 和 &lt;code>String&lt;/code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。&lt;/li>
&lt;li>&lt;strong>在 sendData 層做型別轉換&lt;/strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。&lt;/li>
&lt;li>&lt;strong>Log 送出的 frame type&lt;/strong>：&lt;code>developer.log('WS send: type=${data.runtimeType}')&lt;/code> 讓 debug 時立即可見。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想寫 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想理解 mock 遮蔽的系統性機制 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析&lt;/a>&lt;/li>
&lt;li>類似案例（auth handshake） → &lt;a href="https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 的「API 層級模擬」和真實服務的「協議層級行為」之間的結構性斷裂。WebSocket 的 text frame（opcode 0x1）和 binary frame（opcode 0x2）在 Dart API 層面都是 <code>sink.add(dynamic)</code>，但在協議層是不同的 opcode，ttyd 只接受 text frame。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel Flutter app 連接 ttyd WebSocket 終端機。<code>ConnectionManager.sendData()</code> 接收 <code>Uint8List</code> 型別的鍵盤輸入，直接傳給 <code>_channel!.sink.add(data)</code>。Dart 的 <code>IOWebSocketChannel</code> 對 <code>Uint8List</code> 發送 binary frame（opcode 0x2），ttyd 期望 text frame（opcode 0x1），收到 binary frame 靜默忽略。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>所有鍵盤輸入無效（使用者打字無回應）</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>192 個全過（<code>FakeWebSocketChannel.sink.add</code> 不區分型別）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功但終端機完全無反應</td>
      </tr>
      <tr>
          <td>修復</td>
          <td><code>if (data is Uint8List) sink.add(String.fromCharCodes(data))</code></td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 模擬的是 Dart API 契約，不是 WebSocket 協議契約</strong>。<code>FakeWebSocketChannel</code> 忠實實作了 <code>WebSocketChannel</code> 的 Dart interface — <code>sink.add(dynamic)</code> 接受任何型別。但 <code>IOWebSocketChannel</code> 的 <code>sink.add</code> 實際行為是：<code>String</code> → text frame，<code>List&lt;int&gt;</code> / <code>Uint8List</code> → binary frame。Mock 沒有也不應該模擬這個協議層行為。</p>
</li>
<li>
<p><strong>ttyd 的靜默忽略放大了問題</strong>。如果 ttyd 對 binary frame 回傳錯誤碼或斷線，app 至少會進入 error 狀態讓開發者察覺。靜默忽略讓問題從「連線失敗」變成「連線成功但無回應」，debug 方向完全錯誤。</p>
</li>
<li>
<p><strong>型別系統幫不上忙</strong>。Dart 的 <code>WebSocketSink.add</code> 簽名是 <code>void add(dynamic event)</code> — <code>dynamic</code> 吃掉了型別資訊。即使用強型別語言，如果 API 設計成 <code>dynamic</code>，型別檢查無法區分協議語意。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test</strong>：對真實 ttyd 發送 <code>Uint8List</code> 和 <code>String</code>，斷言兩者行為差異。一個 5 行 test 就能抓到這個問題。</li>
<li><strong>在 sendData 層做型別轉換</strong>：不依賴下游 channel 的行為，在自己的 API 邊界確保型別正確。</li>
<li><strong>Log 送出的 frame type</strong>：<code>developer.log('WS send: type=${data.runtimeType}')</code> 讓 debug 時立即可見。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想寫 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想理解 mock 遮蔽的系統性機制 → <a href="/blog/testing/01-test-strategy-layers/mock-masking-mechanism/" data-link-title="Mock 遮蔽機制分析" data-link-desc="Mock 在 API 層、協議層、環境層之間製造的結構性盲區 — 斷裂點在哪、為什麼 mock 無法也不應該模擬協議行為">Mock 遮蔽機制分析</a></li>
<li>類似案例（auth handshake） → <a href="/blog/testing/cases/auth-handshake-missing-mock-blindspot/" data-link-title="T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽" data-link-desc="ttyd 連線後需要發送 auth token JSON frame 完成認證，整個邏輯未實作 — FakeWebSocketChannel 的 ready 立即完成不需認證，test 永遠看到連線成功">T.C2 Auth handshake 缺失</a></li>
</ul>
]]></content:encoded></item><item><title>T.C2 Auth handshake 邏輯缺失被 FakeWebSocketChannel 遮蔽</title><link>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/auth-handshake-missing-mock-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（&lt;code>{&amp;quot;AuthToken&amp;quot;:&amp;quot;base64(user:pass)&amp;quot;}&lt;/code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 &lt;code>ConnectionManager&lt;/code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。&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>影響範圍&lt;/td>
 &lt;td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Unit test 結果&lt;/td>
 &lt;td>10 個 ConnectionManager test 全過（&lt;code>FakeWebSocketChannel.ready&lt;/code> 立即完成）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>Integration test 結果&lt;/td>
 &lt;td>11 個 connection_flow_test 全過（同樣用 &lt;code>FakeWebSocketChannel&lt;/code>）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>連線成功，終端機空白無輸出&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 &lt;code>_sendAuthTokenIfNeeded()&lt;/code> 在 &lt;code>_establishWebSocket()&lt;/code> 內呼叫&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Mock 的 happy path 比真實服務寬鬆&lt;/strong>。&lt;code>FakeWebSocketChannel&lt;/code> 的 &lt;code>ready&lt;/code> 是 &lt;code>Future.value()&lt;/code>（立即完成），&lt;code>stream&lt;/code> 是開發者手動控制的 &lt;code>StreamController&lt;/code>。真實 ttyd 的行為是：&lt;code>ready&lt;/code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Integration test 名為整合實為 fake&lt;/strong>。&lt;code>connection_flow_test.dart&lt;/code> 標題是「端對端整合測試」，但內部使用 &lt;code>FakeWebSocketChannel&lt;/code> + &lt;code>FakeBiometricService&lt;/code> + &lt;code>InMemoryCredentialRepository&lt;/code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>功能缺失比功能錯誤更難被 test 抓到&lt;/strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>Protocol integration test 必須涵蓋 auth handshake&lt;/strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。&lt;/li>
&lt;li>&lt;strong>在企劃階段列出協議握手步驟&lt;/strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。&lt;/li>
&lt;li>&lt;strong>區分「名義 integration」和「真實 integration」&lt;/strong>：test 名稱含 integration 但全用 fake，應標明 &lt;code>fake-integration&lt;/code> 或改名 &lt;code>connection-state-machine-test&lt;/code>。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想區分 mock 層級 → &lt;a href="https://tarrragon.github.io/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>想設計 auth 機制的 UX fallback → &lt;a href="https://tarrragon.github.io/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 mock 如何讓「功能缺失」變得不可見。不同於 T.C1（功能存在但行為錯誤），這個案例是功能根本沒實作 — 因為 mock 不需要這個功能就能通過所有 test。</p>
<h2 id="觀察">觀察</h2>
<p>ttyd WebSocket 協議要求連線建立後發送一個 JSON frame 包含 base64 編碼的帳密（<code>{&quot;AuthToken&quot;:&quot;base64(user:pass)&quot;}</code>），ttyd 驗證通過後才開始推送 terminal output。app_tunnel 的 <code>ConnectionManager</code> 建立 WS 連線後直接開始監聽 stream，沒有發送 auth token。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>影響範圍</td>
          <td>連線建立後 ttyd 不推送資料（等 auth token），app 顯示空白終端機</td>
      </tr>
      <tr>
          <td>Unit test 結果</td>
          <td>10 個 ConnectionManager test 全過（<code>FakeWebSocketChannel.ready</code> 立即完成）</td>
      </tr>
      <tr>
          <td>Integration test 結果</td>
          <td>11 個 connection_flow_test 全過（同樣用 <code>FakeWebSocketChannel</code>）</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>連線成功，終端機空白無輸出</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 <code>_sendAuthTokenIfNeeded()</code> 在 <code>_establishWebSocket()</code> 內呼叫</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Mock 的 happy path 比真實服務寬鬆</strong>。<code>FakeWebSocketChannel</code> 的 <code>ready</code> 是 <code>Future.value()</code>（立即完成），<code>stream</code> 是開發者手動控制的 <code>StreamController</code>。真實 ttyd 的行為是：<code>ready</code> 完成代表 TCP+WS 握手成功，但 stream 要等 auth token 驗證後才有資料。Mock 把兩步合成一步。</p>
</li>
<li>
<p><strong>Integration test 名為整合實為 fake</strong>。<code>connection_flow_test.dart</code> 標題是「端對端整合測試」，但內部使用 <code>FakeWebSocketChannel</code> + <code>FakeBiometricService</code> + <code>InMemoryCredentialRepository</code> — 三個核心依賴全是 fake。這個 test 驗證的是「假設所有外部服務都正常，內部狀態機是否正確」，不是「真實服務互動是否正確」。</p>
</li>
<li>
<p><strong>功能缺失比功能錯誤更難被 test 抓到</strong>。功能錯誤（T.C1 text vs binary）至少有一個實作可以斷言；功能缺失意味著沒有程式碼可以 test。只有 protocol integration test（對真實服務跑）才能暴露「應該有但沒有」的行為。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>Protocol integration test 必須涵蓋 auth handshake</strong>：連線 → 發送正確 auth token → 斷言收到 output；連線 → 不發送 auth token → 斷言 timeout 或斷線。</li>
<li><strong>在企劃階段列出協議握手步驟</strong>：ttyd WS 協議的 auth handshake 應該在 spec 文件中明確列出，不依賴開發者記得實作。</li>
<li><strong>區分「名義 integration」和「真實 integration」</strong>：test 名稱含 integration 但全用 fake，應標明 <code>fake-integration</code> 或改名 <code>connection-state-machine-test</code>。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想區分 mock 層級 → <a href="/blog/testing/01-test-strategy-layers/" data-link-title="模組一：測試策略分層" data-link-desc="Unit / Protocol Integration / Screen State 三層測試各自的職責、盲區和判斷原則">模組一：測試策略分層</a></li>
<li>想建 protocol integration test → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>想設計 auth 機制的 UX fallback → <a href="/blog/ux-design/cases/biometric-only-no-fallback/" data-link-title="U.C2 biometricOnly=true 無密碼 fallback" data-link-desc="Flutter app 的生物辨識設定 biometricOnly: true 阻擋所有非生物辨識認證方式 — Face ID 不可用時使用者直接被擋住，沒有替代路徑">U.C2 biometricOnly 無 fallback</a></li>
</ul>
]]></content:encoded></item><item><title>T.C3 ANSI parser 測試資料不覆蓋真實 shell output</title><link>https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/ansi-parser-test-data-blindspot/</guid><description>&lt;p>這個案例的核心責任是說明 unit test 的輸入資料品質如何決定測試的有效性。Parser 邏輯正確、斷言正確、覆蓋率高 — 但測試資料是人工挑選的乾淨子集，跟真實環境的輸入分佈不同。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的 &lt;code>AnsiParser&lt;/code> 負責解析終端機輸出的 ANSI escape 序列，轉換為帶色彩的文字 token。unit test 用手寫字串驗證：&lt;/p>





&lt;div class="highlight">&lt;pre tabindex="0" class="chroma">&lt;code class="language-dart" data-lang="dart">&lt;span class="line">&lt;span class="ln">1&lt;/span>&lt;span class="cl">&lt;span class="c1">// 測試資料範例 — 乾淨的 SGR 色彩碼
&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="n">test&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;紅色文字&amp;#39;&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="p">()&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">final&lt;/span> &lt;span class="n">tokens&lt;/span> &lt;span class="o">=&lt;/span> &lt;span class="n">parser&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">parse&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="s1">&amp;#39;&lt;/span>&lt;span class="se">\x1B&lt;/span>&lt;span class="s1">[31mhello&lt;/span>&lt;span class="se">\x1B&lt;/span>&lt;span class="s1">[0m&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="n">expect&lt;/span>&lt;span class="p">(&lt;/span>&lt;span class="n">tokens&lt;/span>&lt;span class="p">.&lt;/span>&lt;span class="n">first&lt;/span>&lt;span class="p">,&lt;/span> &lt;span class="n">isA&lt;/span>&lt;span class="o">&amp;lt;&lt;/span>&lt;span class="n">TextToken&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">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>真實 zsh prompt 啟動後送出的控制序列（擷取自實機 log）：&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">\x1B]0;user@host: ~\x07 ← OSC：設定終端機視窗標題
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">2&lt;/span>&lt;span class="cl">\x1B[?2004h ← CSI private mode：啟用括號貼上模式
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">3&lt;/span>&lt;span class="cl">\x1B[?1h ← CSI private mode：啟用應用程式游標鍵
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">4&lt;/span>&lt;span class="cl">\x1B(B ← 字元集指定：選擇 ASCII
&lt;/span>&lt;/span>&lt;span class="line">&lt;span class="ln">5&lt;/span>&lt;span class="cl">\x1B[?25l ← CSI private mode：隱藏游標&lt;/span>&lt;/span>&lt;/code>&lt;/pre>&lt;/div>&lt;p>Parser 只認識 &lt;code>\x1B[{數字;數字}{字母}&lt;/code> 格式的標準 CSI，其他全部殘留在輸出中。&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>測試案例數&lt;/td>
 &lt;td>18 個 AnsiParser test，全過&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>測試覆蓋的序列類型&lt;/td>
 &lt;td>SGR 色彩碼（&lt;code>\x1B[31m&lt;/code> 等）&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>真實環境的序列類型&lt;/td>
 &lt;td>SGR + OSC + CSI private mode + 字元集指定 + 其他&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>實機表現&lt;/td>
 &lt;td>終端機畫面散佈 &lt;code>]0;user@host&lt;/code> 等亂碼片段&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>修復&lt;/td>
 &lt;td>新增 3 個 RegExp 過濾 OSC / CSI private / 其他 escape&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>測試資料的代表性是隱性假設&lt;/strong>。18 個 test 的斷言都正確 — &lt;code>\x1B[31m&lt;/code> 確實應該產生紅色 token。但「測試輸入能代表真實輸入」是一個未經驗證的假設。真實 zsh 的輸出包含 5+ 種 escape 序列類型，測試只覆蓋了 1 種。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Parser 的行為對未知序列是「透傳」而非「報錯」&lt;/strong>。這是合理的設計 — 不認識的序列不應該讓 parser 崩潰。但透傳的後果是亂碼靜默出現在畫面上，不觸發任何錯誤或 log，開發者在 unit test 環境完全不會察覺。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>手寫測試資料 vs 錄製真實資料&lt;/strong>。如果測試資料是從真實 shell session 錄製的（capture 一次真實 zsh 啟動輸出），OSC 和 CSI private mode 會自然出現在測試輸入中，parser 的透傳行為會在 test 階段就被看到。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>從真實環境錄製測試資料&lt;/strong>：用 &lt;code>script&lt;/code> 命令或 WebSocket log 錄一次真實 shell session 的完整輸出，作為 integration test 的輸入。即使不改 parser 邏輯，至少能看到「哪些序列被透傳了」。&lt;/li>
&lt;li>&lt;strong>Parser 對未知序列記 warning log&lt;/strong>：透傳是合理的 fallback，但加一行 &lt;code>developer.log('Unknown escape: ${escape.codeUnits}')&lt;/code> 讓開發者知道有未處理的序列。&lt;/li>
&lt;li>&lt;strong>測試分兩類&lt;/strong>：「功能正確性」用手寫乾淨字串；「環境相容性」用錄製的真實輸出。兩類測試回答不同問題。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想理解測試資料代表性 → &lt;a href="https://tarrragon.github.io/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">Test data 代表性&lt;/a>&lt;/li>
&lt;li>想建 protocol integration test 用真實 ttyd 輸出 → &lt;a href="https://tarrragon.github.io/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試&lt;/a>&lt;/li>
&lt;li>類似案例（mock 遮蔽） → &lt;a href="https://tarrragon.github.io/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type mock 遮蔽&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明 unit test 的輸入資料品質如何決定測試的有效性。Parser 邏輯正確、斷言正確、覆蓋率高 — 但測試資料是人工挑選的乾淨子集，跟真實環境的輸入分佈不同。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的 <code>AnsiParser</code> 負責解析終端機輸出的 ANSI escape 序列，轉換為帶色彩的文字 token。unit test 用手寫字串驗證：</p>





<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-dart" data-lang="dart"><span class="line"><span class="ln">1</span><span class="cl"><span class="c1">// 測試資料範例 — 乾淨的 SGR 色彩碼
</span></span></span><span class="line"><span class="ln">2</span><span class="cl"><span class="c1"></span><span class="n">test</span><span class="p">(</span><span class="s1">&#39;紅色文字&#39;</span><span class="p">,</span> <span class="p">()</span> <span class="p">{</span>
</span></span><span class="line"><span class="ln">3</span><span class="cl">  <span class="kd">final</span> <span class="n">tokens</span> <span class="o">=</span> <span class="n">parser</span><span class="p">.</span><span class="n">parse</span><span class="p">(</span><span class="s1">&#39;</span><span class="se">\x1B</span><span class="s1">[31mhello</span><span class="se">\x1B</span><span class="s1">[0m&#39;</span><span class="p">);</span>
</span></span><span class="line"><span class="ln">4</span><span class="cl">  <span class="n">expect</span><span class="p">(</span><span class="n">tokens</span><span class="p">.</span><span class="n">first</span><span class="p">,</span> <span class="n">isA</span><span class="o">&lt;</span><span class="n">TextToken</span><span class="o">&gt;</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>真實 zsh prompt 啟動後送出的控制序列（擷取自實機 log）：</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">\x1B]0;user@host: ~\x07          ← OSC：設定終端機視窗標題
</span></span><span class="line"><span class="ln">2</span><span class="cl">\x1B[?2004h                      ← CSI private mode：啟用括號貼上模式
</span></span><span class="line"><span class="ln">3</span><span class="cl">\x1B[?1h                         ← CSI private mode：啟用應用程式游標鍵
</span></span><span class="line"><span class="ln">4</span><span class="cl">\x1B(B                           ← 字元集指定：選擇 ASCII
</span></span><span class="line"><span class="ln">5</span><span class="cl">\x1B[?25l                        ← CSI private mode：隱藏游標</span></span></code></pre></div><p>Parser 只認識 <code>\x1B[{數字;數字}{字母}</code> 格式的標準 CSI，其他全部殘留在輸出中。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>測試案例數</td>
          <td>18 個 AnsiParser test，全過</td>
      </tr>
      <tr>
          <td>測試覆蓋的序列類型</td>
          <td>SGR 色彩碼（<code>\x1B[31m</code> 等）</td>
      </tr>
      <tr>
          <td>真實環境的序列類型</td>
          <td>SGR + OSC + CSI private mode + 字元集指定 + 其他</td>
      </tr>
      <tr>
          <td>實機表現</td>
          <td>終端機畫面散佈 <code>]0;user@host</code> 等亂碼片段</td>
      </tr>
      <tr>
          <td>修復</td>
          <td>新增 3 個 RegExp 過濾 OSC / CSI private / 其他 escape</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>測試資料的代表性是隱性假設</strong>。18 個 test 的斷言都正確 — <code>\x1B[31m</code> 確實應該產生紅色 token。但「測試輸入能代表真實輸入」是一個未經驗證的假設。真實 zsh 的輸出包含 5+ 種 escape 序列類型，測試只覆蓋了 1 種。</p>
</li>
<li>
<p><strong>Parser 的行為對未知序列是「透傳」而非「報錯」</strong>。這是合理的設計 — 不認識的序列不應該讓 parser 崩潰。但透傳的後果是亂碼靜默出現在畫面上，不觸發任何錯誤或 log，開發者在 unit test 環境完全不會察覺。</p>
</li>
<li>
<p><strong>手寫測試資料 vs 錄製真實資料</strong>。如果測試資料是從真實 shell session 錄製的（capture 一次真實 zsh 啟動輸出），OSC 和 CSI private mode 會自然出現在測試輸入中，parser 的透傳行為會在 test 階段就被看到。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>從真實環境錄製測試資料</strong>：用 <code>script</code> 命令或 WebSocket log 錄一次真實 shell session 的完整輸出，作為 integration test 的輸入。即使不改 parser 邏輯，至少能看到「哪些序列被透傳了」。</li>
<li><strong>Parser 對未知序列記 warning log</strong>：透傳是合理的 fallback，但加一行 <code>developer.log('Unknown escape: ${escape.codeUnits}')</code> 讓開發者知道有未處理的序列。</li>
<li><strong>測試分兩類</strong>：「功能正確性」用手寫乾淨字串；「環境相容性」用錄製的真實輸出。兩類測試回答不同問題。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想理解測試資料代表性 → <a href="/blog/testing/05-test-design-judgment/test-data-representativeness/" data-link-title="Test data 代表性" data-link-desc="手寫 vs 錄製 vs 生成三種測試資料來源 — 測試資料的代表性是一個隱性假設，決定了 test 能發現什麼問題">Test data 代表性</a></li>
<li>想建 protocol integration test 用真實 ttyd 輸出 → <a href="/blog/testing/03-protocol-integration-test/" data-link-title="模組三：協議整合測試" data-link-desc="對真實服務驗證 WebSocket / gRPC / HTTP 協議契約 — unit test 和 E2E test 之間的一層">模組三：協議整合測試</a></li>
<li>類似案例（mock 遮蔽） → <a href="/blog/testing/cases/ws-text-binary-frame-mock-blindspot/" data-link-title="T.C1 WebSocket text/binary frame 被 FakeWebSocketChannel 遮蔽" data-link-desc="Flutter app 用 Uint8List 發送 WS 資料走 binary frame，ttyd 期望 text frame 靜默忽略 — FakeWebSocketChannel 的 sink.add 接受 dynamic 不區分 frame type，192 個 test 全過但實機無回應">T.C1 WS frame type mock 遮蔽</a></li>
</ul>
]]></content:encoded></item><item><title>T.C4 Client-side log 缺失導致 debug 只能靠實機盲測</title><link>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</link><pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate><guid>https://tarrragon.github.io/blog/testing/cases/client-log-absent-debug-cost/</guid><description>&lt;p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。&lt;/p>
&lt;h2 id="觀察">觀察&lt;/h2>
&lt;p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>元件&lt;/th>
 &lt;th>log 點數&lt;/th>
 &lt;th>備註&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>ConnectionManager&lt;/td>
 &lt;td>0 → 10&lt;/td>
 &lt;td>W2 修復後補的 &lt;code>developer.log&lt;/code>&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TerminalScreen&lt;/td>
 &lt;td>0 → 5&lt;/td>
 &lt;td>W2 修復後補的&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>TtydProtocol&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>encode/decode/buildAuth 無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>BiometricService&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>isAvailable/authenticate 結果無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>CredentialRepository&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>load/save/delete 操作無 log&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>EnrollmentScreen&lt;/td>
 &lt;td>0&lt;/td>
 &lt;td>QR 掃描/解析/儲存無 log&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 &lt;code>developer.log&lt;/code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。&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>debug 成本&lt;/td>
 &lt;td>每次修改→編譯→部署→測試約 3-5 分鐘&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>定位 W2-002 (auth token) 花費&lt;/td>
 &lt;td>約 30 分鐘反覆測試&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>若有連線生命週期 log&lt;/td>
 &lt;td>第一次連線就能看到「Step 3 之後無 auth token 發送」&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="判讀">判讀&lt;/h2>
&lt;ol>
&lt;li>
&lt;p>&lt;strong>Log 缺失把 debug 成本從秒級升到分鐘級&lt;/strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>「事後補 log」的 log 品質較低&lt;/strong>。W2 修復時補的 &lt;code>developer.log&lt;/code> 格式不統一（有的帶 &lt;code>name:&lt;/code>，有的不帶；有的用 &lt;code>// i18n-exempt&lt;/code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>自用工具最適合自架 log 收集&lt;/strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。&lt;/p>
&lt;/li>
&lt;li>
&lt;p>&lt;strong>Log 設計是功能規格的一部分&lt;/strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。&lt;/p>
&lt;/li>
&lt;/ol>
&lt;h2 id="策略">策略&lt;/h2>
&lt;ol>
&lt;li>&lt;strong>功能規格階段列出 log 點清單&lt;/strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。&lt;/li>
&lt;li>&lt;strong>建立統一 log 層&lt;/strong>：封裝 &lt;code>developer.log&lt;/code> 為 &lt;code>AppLogger&lt;/code>，統一 name、level、格式。開發期用 &lt;code>developer.log&lt;/code>，後續可切換到 HTTP log endpoint。&lt;/li>
&lt;li>&lt;strong>自架 log endpoint 方案&lt;/strong>：本機 Go server 開一個 &lt;code>/log&lt;/code> POST endpoint，接收 JSON log，寫入檔案。Client 端 &lt;code>AppLogger&lt;/code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。&lt;/li>
&lt;li>&lt;strong>Protocol log 獨立一層&lt;/strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。&lt;/li>
&lt;/ol>
&lt;h2 id="下一步路由">下一步路由&lt;/h2>
&lt;ul>
&lt;li>想設計客戶端 log 方案 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性&lt;/a>&lt;/li>
&lt;li>想理解三層 log 設計 → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計&lt;/a>&lt;/li>
&lt;li>想建自架 log endpoint → &lt;a href="https://tarrragon.github.io/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &amp;#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案&lt;/a>&lt;/li>
&lt;/ul></description><content:encoded><![CDATA[<p>這個案例的核心責任是說明「客戶端 log 設計」為什麼應該在功能企劃階段完成，而不是 debug 時才補。Log 不是 debug 工具，是可觀測性基礎設施。</p>
<h2 id="觀察">觀察</h2>
<p>app_tunnel 的六個核心元件在實機測試前的 log 覆蓋狀態：</p>
<table>
  <thead>
      <tr>
          <th>元件</th>
          <th>log 點數</th>
          <th>備註</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ConnectionManager</td>
          <td>0 → 10</td>
          <td>W2 修復後補的 <code>developer.log</code></td>
      </tr>
      <tr>
          <td>TerminalScreen</td>
          <td>0 → 5</td>
          <td>W2 修復後補的</td>
      </tr>
      <tr>
          <td>TtydProtocol</td>
          <td>0</td>
          <td>encode/decode/buildAuth 無 log</td>
      </tr>
      <tr>
          <td>BiometricService</td>
          <td>0</td>
          <td>isAvailable/authenticate 結果無 log</td>
      </tr>
      <tr>
          <td>CredentialRepository</td>
          <td>0</td>
          <td>load/save/delete 操作無 log</td>
      </tr>
      <tr>
          <td>EnrollmentScreen</td>
          <td>0</td>
          <td>QR 掃描/解析/儲存無 log</td>
      </tr>
  </tbody>
</table>
<p>W2-004（P0：iOS 實機 WS stream 不觸發）的 debug 過程：無法從任何 log 判斷問題發生在 biometric → credential → WS connect → auth token → stream listen 的哪一步。開發者被迫在每個函式手動加 <code>developer.log</code>，重新編譯，插拔裝置測試，反覆數次才定位到「stream 訂閱時機」問題。</p>
<table>
  <thead>
      <tr>
          <th>指標</th>
          <th>值</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>debug 成本</td>
          <td>每次修改→編譯→部署→測試約 3-5 分鐘</td>
      </tr>
      <tr>
          <td>定位 W2-002 (auth token) 花費</td>
          <td>約 30 分鐘反覆測試</td>
      </tr>
      <tr>
          <td>若有連線生命週期 log</td>
          <td>第一次連線就能看到「Step 3 之後無 auth token 發送」</td>
      </tr>
  </tbody>
</table>
<h2 id="判讀">判讀</h2>
<ol>
<li>
<p><strong>Log 缺失把 debug 成本從秒級升到分鐘級</strong>。如果 ConnectionManager 在企劃階段就設計了「Step 1: biometric → Step 2: credential → Step 3: WS connect → Step 4: auth token → Step 5: listen stream」五步 log，W2-002 的 auth token 問題在第一次連線就能從 log 看到「Step 3 完成，Step 4 未執行」。</p>
</li>
<li>
<p><strong>「事後補 log」的 log 品質較低</strong>。W2 修復時補的 <code>developer.log</code> 格式不統一（有的帶 <code>name:</code>，有的不帶；有的用 <code>// i18n-exempt</code> 標記，有的忘了），沒有統一的 log 層級，沒有結構化欄位。事後補的 log 是救火工具，不是可觀測性設計。</p>
</li>
<li>
<p><strong>自用工具最適合自架 log 收集</strong>。app_tunnel 的 server 和 client 都在同一台機器上（或同一個 Tailscale tailnet），client 可以直接打 HTTP POST 到本機的 log endpoint，不需要 Sentry 或 Crashlytics。一個 Go 寫的 JSON log receiver（20 行）+ grep 就是完整的 debug 工具鏈。</p>
</li>
<li>
<p><strong>Log 設計是功能規格的一部分</strong>。「連線到 ttyd 終端機」這個功能的規格不只是「建立 WS 連線」，還包含「每步有 log、失敗有 log、成功有 log」。跟 API 規格需要定義 request/response 一樣，連線功能需要定義 log 點。</p>
</li>
</ol>
<h2 id="策略">策略</h2>
<ol>
<li><strong>功能規格階段列出 log 點清單</strong>：每個功能的規格文件新增「可觀測性」欄位，列出啟動/步驟/錯誤/完成四類 log 點。</li>
<li><strong>建立統一 log 層</strong>：封裝 <code>developer.log</code> 為 <code>AppLogger</code>，統一 name、level、格式。開發期用 <code>developer.log</code>，後續可切換到 HTTP log endpoint。</li>
<li><strong>自架 log endpoint 方案</strong>：本機 Go server 開一個 <code>/log</code> POST endpoint，接收 JSON log，寫入檔案。Client 端 <code>AppLogger</code> 在 debug mode 同時寫 console + POST 到 endpoint。開發期 grep 查詢，不需要 dashboard。</li>
<li><strong>Protocol log 獨立一層</strong>：WebSocket frame type、payload 前綴、auth handshake 結果獨立記錄，跟 business log 分開。這層 log 在 release mode 應該能關閉。</li>
</ol>
<h2 id="下一步路由">下一步路由</h2>
<ul>
<li>想設計客戶端 log 方案 → <a href="/blog/testing/02-client-observability/" data-link-title="模組二：客戶端可觀測性" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — log 設計是功能規格的一部分">模組二：客戶端可觀測性</a></li>
<li>想理解三層 log 設計 → <a href="/blog/testing/02-client-observability/three-layer-log-design/" data-link-title="三層 log 設計" data-link-desc="連線生命週期 log、protocol 訊息 log、使用者行為 log — 三層各自的職責、詳細程度和啟停控制">三層 log 設計</a></li>
<li>想建自架 log endpoint → <a href="/blog/testing/02-client-observability/log-endpoint-tradeoff/" data-link-title="自架 log endpoint vs 商業方案的取捨判斷" data-link-desc="自用工具用自架 log receiver（20 行 Go &#43; grep）、商業 app 用 Sentry/Crashlytics — 判斷依據是使用者規模和 debug 需求">自架 log endpoint vs 商業方案</a></li>
</ul>
]]></content:encoded></item></channel></rss>