Files
pangolin/docs/test-architecture.html
wangjia 55d368d98b docs(test): 测试架构补「⑩ 已知缺口/测试盲区」单一跟踪源
把散在对话里的盲区收进架构文档,作为单一跟踪源(补一项划一项):
- A 覆盖盲区:前端统计上屏数值未验证(诉求半覆盖)、真实流量数据路径
  (注入非真出网,最大盲区)、原生隧道层零自动化、契约双份可能漂移。
- B 运维/工程缺口:CI 单点 mac(持久化未激活)、go-integration 裸宿主脆性、
  analyze 严格化(B1)+ golden 全集(B2)被 stats-overhaul 耦合阻塞、
  golden 本地难重生。
每项标严重度 + 现状为什么 + 何时怎么补;同步登记 docs/index.html。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:30:54 +08:00

506 lines
34 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pangolin 统一测试框架 · 架构说明</title>
<style>
:root{
--bg:#0f1117; --panel:#171a22; --panel2:#1d2129; --fg:#e6e8ee; --fg2:#a8afbd;
--accent:#e0884f; --accent2:#5fb0c9; --ok:#5ec27a; --bad:#e06a6a; --warn:#e0b84f;
--border:#272c36; --mono:"SF Mono",ui-monospace,Menlo,Consolas,monospace;
--sans:-apple-system,"PingFang SC","Helvetica Neue",Arial,sans-serif;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font-family:var(--sans);line-height:1.7;font-size:15px}
.wrap{max-width:920px;margin:0 auto;padding:48px 24px 96px}
h1{font-size:30px;line-height:1.3;margin:0 0 8px;letter-spacing:-.01em}
.sub{color:var(--fg2);font-size:15px;margin:0 0 32px}
h2{font-size:21px;margin:44px 0 14px;padding-bottom:8px;border-bottom:1px solid var(--border)}
h3{font-size:16.5px;margin:24px 0 8px;color:var(--accent)}
p{margin:10px 0}
code{font-family:var(--mono);font-size:.88em;background:var(--panel2);padding:1px 6px;border-radius:5px;color:#f0d9c4}
a{color:var(--accent2);text-decoration:none}
a:hover{text-decoration:underline}
ul{margin:8px 0;padding-left:22px}
li{margin:5px 0}
.lead{background:linear-gradient(180deg,rgba(224,136,79,.10),transparent);border:1px solid var(--border);border-radius:12px;padding:18px 20px;margin:0 0 8px}
.small{color:var(--fg2);font-size:13px}
.note{color:var(--fg2);font-size:13px;border-left:3px solid var(--border);padding-left:12px;margin:10px 0}
.fig{background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:18px;margin:18px 0;overflow-x:auto}
.fig .cap{color:var(--fg2);font-size:12.5px;margin:0 0 10px;font-weight:600;letter-spacing:.02em}
.fig svg{display:block;max-width:100%;height:auto}
.phase{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin:14px 0}
.phase .badge{display:inline-block;font-size:11px;font-weight:700;padding:2px 9px;border-radius:999px;margin-bottom:8px}
.bL0{background:rgba(95,176,201,.16);color:var(--accent2)}
.bL1{background:rgba(94,194,122,.16);color:var(--ok)}
.bL2{background:rgba(95,176,201,.16);color:var(--accent2)}
.bL3{background:rgba(224,136,79,.16);color:var(--accent)}
.bL4{background:rgba(224,106,106,.16);color:var(--bad)}
table{width:100%;border-collapse:collapse;margin:14px 0;font-size:13.5px}
th,td{border:1px solid var(--border);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--panel2);color:var(--fg);font-weight:600}
td{color:var(--fg2)}
td b,td strong{color:var(--fg)}
.tag{display:inline-block;font-size:11px;font-weight:700;padding:1px 8px;border-radius:999px;white-space:nowrap}
.t-ok{background:rgba(94,194,122,.16);color:var(--ok)}
.t-warn{background:rgba(224,184,79,.16);color:var(--warn)}
.t-bad{background:rgba(224,106,106,.16);color:var(--bad)}
.kbd{font-family:var(--mono);font-size:.86em;color:#f0d9c4}
</style>
</head>
<body>
<div class="wrap">
<h1>Pangolin 统一测试框架 · 架构说明</h1>
<p class="sub">分层方法论 + 脚手架模板 + 单一编排入口 · 可自动化 / 可量化 / 可自定义</p>
<div class="lead">
<strong>定性(方案 A):</strong>不存在一个能同时覆盖「界面渲染 / 交互驱动 / 后端记账 / 真链路端到端」的单一工具——这四者运行时根本不同。所以「一套测试框架」= 一套<b>分层方法论</b> + 一组<b>可复用脚手架模板</b>(假桥、Provider 覆盖、golden+字体锁、Go 真库套、E2E compose 骨架)+ 一个<b>统一编排入口</b>。跨多端项目的复用方式是<b>模板复制后按接缝适配</b>,而非全项目 import 同一个包。
<div class="small" style="margin-top:8px">↔ 配套文档:<a href="dev-conventions.html">开发规范 · 可测试性五支柱</a>(「怎么写才好测」——测试能力的前置条件)</div>
</div>
<h2>设计原则</h2>
<ul>
<li><b>测试金字塔</b>:底层用例多、快、稳、便宜;越往上越接近真实、越慢、越脆。绝大多数断言压在底部,顶部只留薄薄一层冒烟。</li>
<li><b>契约接缝(contract seam)优先</b>:系统由若干跨进程/跨语言的接缝拼成(原生→Dart、Dart→控制面、agent→控制面)。每段接缝先有<b>契约快照</b>守住「字段形状」,再分层在接缝两侧各自下刀。</li>
<li><b>量化优先</b>:每层都产出可比对的数字(覆盖率、像素不一致比例、数值容差、时延 ms、通过率),而非「人眼看着对」。</li>
<li><b>单一编排</b>:一个入口(<code class="kbd">make test</code> / <code class="kbd">test.sh</code>)按 profile 跑对应层,输出一份合并报告。</li>
</ul>
<p class="note">前不久的「mac 实时统计恒为 0」就是典型反例:原生侧产出统计、Dart 侧消费统计,两端的 <code>pangolin/vpn/stats</code> 契约没有任何自动化测试守门,触发条件一变(隧道已连接才启动 app)就静默失效。<b>L0 契约层防的正是这类 bug。</b></p>
<h2>① 分层测试金字塔</h2>
<div class="fig">
<p class="cap">图 1 · 五层金字塔:L0 契约为地基,L1→L4 自底向上</p>
<svg viewBox="0 0 860 400" role="img" aria-label="测试金字塔分层图">
<!-- pyramid bands -->
<polygon points="430,46 360,112 500,112" fill="rgba(224,106,106,.20)" stroke="var(--bad)" stroke-width="1.5"/>
<polygon points="360,112 500,112 575,178 285,178" fill="rgba(224,136,79,.18)" stroke="var(--accent)" stroke-width="1.5"/>
<polygon points="285,178 575,178 650,244 210,244" fill="rgba(95,176,201,.16)" stroke="var(--accent2)" stroke-width="1.5"/>
<polygon points="210,244 650,244 725,310 135,310" fill="rgba(94,194,122,.16)" stroke="var(--ok)" stroke-width="1.5"/>
<!-- foundation L0 -->
<rect x="135" y="324" width="590" height="48" rx="8" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<!-- labels -->
<text x="430" y="96" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">L4 E2E</text>
<text x="430" y="152" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L3 视觉一致性</text>
<text x="430" y="166" text-anchor="middle" fill="var(--fg2)" font-size="11">golden · pixelmatch</text>
<text x="430" y="212" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L2 集成 · 交互</text>
<text x="430" y="226" text-anchor="middle" fill="var(--fg2)" font-size="11">flutter flow · httptest · bufconn · 真库</text>
<text x="430" y="280" text-anchor="middle" fill="var(--fg)" font-size="14" font-weight="700">L1 单元</text>
<text x="430" y="294" text-anchor="middle" fill="var(--fg2)" font-size="11">flutter_test · go test+testify(纯逻辑)</text>
<text x="430" y="346" text-anchor="middle" fill="var(--accent2)" font-size="13" font-weight="700">L0 契约 / 静态闸 · 地基</text>
<text x="430" y="363" text-anchor="middle" fill="var(--fg2)" font-size="11">契约快照 · OpenAPI 校验 · analyze/vet · 红线词扫描</text>
<!-- L4 callout (apex too small for text) -->
<line x1="500" y1="79" x2="700" y2="79" stroke="var(--border)" stroke-width="1"/>
<text x="705" y="76" fill="var(--bad)" font-size="11.5" font-weight="700">L4 E2E</text>
<text x="705" y="91" fill="var(--fg2)" font-size="10.5">docker-compose 真链路冒烟</text>
<!-- right axis arrows -->
<line x1="800" y1="60" x2="800" y2="310" stroke="var(--border)" stroke-width="1.5"/>
<polygon points="800,310 796,300 804,300" fill="var(--fg2)"/>
<polygon points="800,60 796,70 804,70" fill="var(--fg2)"/>
<text x="818" y="120" fill="var(--fg2)" font-size="10.5" transform="rotate(90 818,120)">越往上:越真实·越慢·越脆·越少</text>
<text x="836" y="250" fill="var(--fg2)" font-size="10.5" transform="rotate(90 836,250)">越往下:越多·越快·越稳·越省</text>
</svg>
</div>
<h2>② 契约接缝数据流</h2>
<p>统计/记账数据沿一条跨进程链路流动。每段接缝标注其<b>契约</b>,以及<b>哪一层测试在此下刀</b>——这张图就是「该测什么、在哪测」的地图。</p>
<div class="fig">
<p class="cap">图 2 · 契约接缝:从节点数据面一路回到客户端 UI</p>
<svg viewBox="0 0 860 250" role="img" aria-label="契约接缝数据流图">
<!-- boxes -->
<g font-size="12.5" font-weight="700">
<rect x="8" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="83" y="99" text-anchor="middle" fill="var(--fg)">原生隧道 / libbox</text>
<text x="83" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">sing-box(客户端)</text>
<rect x="186" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="261" y="99" text-anchor="middle" fill="var(--fg)">Flutter Dart 层</text>
<text x="261" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">UI / Riverpod</text>
<rect x="364" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="439" y="99" text-anchor="middle" fill="var(--fg)">Go 控制面</text>
<text x="439" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">:8080 HTTP + DB</text>
<rect x="542" y="74" width="150" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="617" y="99" text-anchor="middle" fill="var(--fg)">agent</text>
<text x="617" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">:9443 gRPC mTLS</text>
<rect x="720" y="74" width="132" height="58" rx="9" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="786" y="99" text-anchor="middle" fill="var(--fg)">节点数据面</text>
<text x="786" y="116" text-anchor="middle" fill="var(--fg2)" font-size="11" font-weight="400">sing-box 出网</text>
</g>
<!-- arrows between -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="186" y1="103" x2="160" y2="103"/><polygon points="158,103 168,99 168,107"/>
<line x1="364" y1="103" x2="338" y2="103"/><polygon points="336,103 346,99 346,107"/>
<line x1="542" y1="103" x2="516" y2="103"/><polygon points="514,103 524,99 524,107"/>
<line x1="720" y1="103" x2="694" y2="103"/><polygon points="692,103 702,99 702,107"/>
</g>
<!-- contract labels (above) -->
<g font-size="10.5" fill="var(--accent2)" text-anchor="middle" font-weight="700">
<text x="172" y="58">① stats EventChannel</text>
<text x="350" y="58">② /v1/* HTTP JSON</text>
<text x="528" y="58">③ UsageEntry gRPC</text>
<text x="707" y="58">④ V2Ray stats</text>
</g>
<!-- which test cuts here (below) -->
<g font-size="10" text-anchor="middle">
<rect x="112" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="172" y="166" fill="var(--ok)" font-weight="700">L0 契约 + L2 flow</text>
<text x="172" y="180" fill="var(--fg2)">VpnBridgeMock</text>
<rect x="290" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="350" y="166" fill="var(--ok)" font-weight="700">L2 httptest</text>
<text x="350" y="180" fill="var(--fg2)">ApiClient 注入</text>
<rect x="468" y="150" width="120" height="40" rx="6" fill="rgba(94,194,122,.12)" stroke="var(--ok)" stroke-width="1"/>
<text x="528" y="166" fill="var(--ok)" font-weight="700">L2 bufconn ✓强</text>
<text x="528" y="180" fill="var(--fg2)">自签 CA/CRL</text>
<rect x="646" y="150" width="122" height="40" rx="6" fill="rgba(224,106,106,.12)" stroke="var(--bad)" stroke-width="1"/>
<text x="707" y="166" fill="var(--bad)" font-weight="700">L4 真流量 ✗缺</text>
<text x="707" y="180" fill="var(--fg2)">E2E 待建</text>
</g>
<!-- connector ticks -->
<g stroke="var(--border)" stroke-width="1" stroke-dasharray="3 3">
<line x1="172" y1="132" x2="172" y2="150"/>
<line x1="350" y1="132" x2="350" y2="150"/>
<line x1="528" y1="132" x2="528" y2="150"/>
<line x1="707" y1="132" x2="707" y2="150"/>
</g>
</svg>
</div>
<p class="note">实时上下行走 ①(libbox→EventChannel→Dart);累计流量/周柱走 ④→③→DB→②(节点统计→agent→控制面入库→客户端拉取)。两条流共用同一张接缝图,测试各自归位。</p>
<h2>③ 四类诉求 → 层 → 工具 → 量化指标</h2>
<p>把你提出的四类测试诉求逐条落到层、工具、量化指标与现状:</p>
<table>
<thead><tr><th style="width:20%">诉求</th><th style="width:8%"></th><th style="width:30%">工具</th><th style="width:26%">量化指标</th><th style="width:16%">现状</th></tr></thead>
<tbody>
<tr>
<td><b>① 界面设计一致</b><br><span class="small">每端一致</span></td>
<td>L3</td>
<td>Flutter <b>golden</b>(字体锁定,host 无关→天然每端一致);design-distill <b>pixelmatch</b> 对照 HTML 原型真相源</td>
<td>像素不一致比例 vs 阈值 <code>0.05</code>golden 通过/失败</td>
<td><span class="tag t-warn">素材齐 · CI 没跑</span></td>
</tr>
<tr>
<td><b>② 前端交互</b><br><span class="small">连接 / 登陆 / 登出</span></td>
<td>L2</td>
<td><b>flutter_test 进程内驱动整树</b> + VpnBridgeMock + http MockClient + ProviderScope 覆盖</td>
<td>流程通过/失败;覆盖率 lcov</td>
<td><span class="tag t-warn">素材齐 · 待组装</span></td>
</tr>
<tr>
<td><b>③ 数值型</b><br><span class="small">统计 / 实时速度 / 时延</span></td>
<td>L1 + L4</td>
<td>L1:格式化/换算纯逻辑单测;L4:连真节点拉 stats 断言 bytes 增长、延迟 &gt;0</td>
<td>实测 vs 期望<b>容差</b>;时延 ms;速率 B/s &gt;0</td>
<td><span class="tag t-warn">L1 可补</span> <span class="tag t-bad">L4 缺</span></td>
</tr>
<tr>
<td><b>④ 后端网络</b><br><span class="small">按租户流量 / 限流</span></td>
<td>L2 + L4</td>
<td>L2SQLite 真库 + testcontainers MySQL(已覆盖并发/重放/跨日/配额);L4:全链路真流量计数</td>
<td>记账数值断言;限流是否触发;覆盖率 go cover</td>
<td><span class="tag t-ok">L2 强</span> <span class="tag t-bad">L4 缺</span></td>
</tr>
</tbody>
</table>
<h2>④ 逐层详解</h2>
<div class="phase">
<span class="badge bL0">L0 · 契约 / 静态闸</span>
<h3 style="margin-top:0">守住接缝的「字段形状」</h3>
<ul>
<li><b>测什么</b>:跨进程契约的结构——<code>pangolin/vpn/stats</code> EventChannel 字段、gRPC <code>UsageEntry</code> proto、<code>/v1/me</code>·<code>/v1/usage</code> JSON 形态;静态:analyze/vet、OpenAPI 结构、红线词。</li>
<li><b>工具</b>:契约快照测试(snapshot+ 已有 OpenAPI 校验 + <code>ci/scan-redline.sh</code></li>
<li><b>量化</b>:字段 diff = 0;扫描命中 = 0。</li>
<li><b>现状/缺口</b><span class="tag t-ok">OpenAPI/红线已有</span> <span class="tag t-warn">契约快照需补</span><b>这是最被低估、性价比最高的一层。</b></li>
<li><b>可自定义</b>:新增一段接缝 = 加一份快照基线。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL1">L1 · 单元</span>
<h3 style="margin-top:0">纯逻辑,最快最稳</h3>
<ul>
<li><b>测什么</b>:数值换算(bytes→速率/时长格式化)、状态机(ConnectionController)、记账纯函数(GB 卡控、dp_uuid 解析)。</li>
<li><b>工具</b><code>flutter_test</code><code>go test</code> + testify。</li>
<li><b>量化</b>:覆盖率(lcov / <code>go test -cover</code>)。</li>
<li><b>现状</b><span class="tag t-ok">已有</span>(前端 controller、后端 usage 包多场景)。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL2">L2 · 集成 / 交互</span>
<h3 style="margin-top:0">带假替身的真实路径</h3>
<ul>
<li><b>前端交互</b><code>pumpWidget(真 App 树)</code> 驱动 登陆→重启续登→点连接→状态流转→登出,把原生隧道换 <b>VpnBridgeMock</b>、服务端换 <b>MockClient</b><code>ProviderScope</code> 覆盖注入。</li>
<li><b>后端 DB</b>SQLite 内存真库 + testcontainers MySQL/Redis(已覆盖并发去重/重放/跨日分桶/配额)。</li>
<li><b>后端 HTTP/gRPC</b><code>httptest.Server</code>(鉴权路径需补);gRPC <code>bufconn</code> + 自签 CA/CRL(已强)。</li>
<li><b>量化</b>:通过/失败 + 覆盖率 + 数值断言。</li>
<li><b>现状</b><span class="tag t-ok">后端强</span> <span class="tag t-warn">前端 flow 待组装 · HTTP 鉴权偏薄</span></li>
</ul>
</div>
<div class="phase">
<span class="badge bL3">L3 · 视觉一致性</span>
<h3 style="margin-top:0">每端渲染一致 + 对照设计真相源</h3>
<ul>
<li><b>测什么</b>mobile/tablet/desktop × light/dark × zh/en 的渲染一致;以及实现 vs HTML 原型的像素差。</li>
<li><b>工具</b>Flutter golden<code>flutter_test_config.dart</code> 字体锁定→host 无关,所以「每端效果一致」天然成立);design-distill <code>shoot-prototype.mjs</code> + <code>diff.mjs</code>pixelmatch)。</li>
<li><b>量化</b>:像素不一致比例 vs 阈值 <code>0.05</code></li>
<li><b>现状</b><span class="tag t-ok">已进 CI(子集)</span> components + auth golden 跑 Linux 权威基线、CI job <code>golden</code> 阻塞守门;权威环境脚本 <code>scripts/update-goldens.sh</code><span class="tag t-warn">tablet/desktop-stats golden 待并入</span>——它们与 stats-overhaul dirty 工作区耦合(基线/测试/被测 widget 同在 dirty),待其合并后跑 <code>update-goldens.sh</code> 生成 Linux 基线并加进 <code>golden</code> job。</li>
<li><b>可自定义</b>:新增屏 = 加一张基线;阈值按屏可调。</li>
</ul>
</div>
<div class="phase">
<span class="badge bL4">L4 · 端到端冒烟</span>
<h3 style="margin-top:0">真链路:统计真不真 / 流量真不真</h3>
<ul>
<li><b>测什么</b>:起真控制面 + agent + sing-box + DB,发真流量,断言按租户/按设备 bytes 入库、限流触发、实时数值 &gt;0。</li>
<li><b>工具</b>docker-compose + 探针驱动脚本(见图 5)。</li>
<li><b>量化</b>:实测 vs 期望容差、限流是否触发、连上耗时、时延 ms。</li>
<li><b>现状</b><span class="tag t-ok">已落地</span> <code>scripts/e2e-smoke.sh</code> + <code>server/test/e2e/</code>——进程级:真 server 二进制 + sqlite + 内嵌 miniredis + gRPC enroll/ReportUsage → <code>/v1/usage</code> 断言统计真入库真读出(CI job <code>e2e-smoke</code>)。真 sing-box 出网流量留后续。</li>
<li><b>边界(建议)</b>:先做 <b>(a) 后端全链路</b>(可全自动、进一台 Linux runner);<b>(b) desktop 真连</b>(需常驻 Mac)与 <b>(c) iOS/Android 真机</b> 作本地按需后续。</li>
</ul>
</div>
<h2>⑤ 编排与 CI 流水线</h2>
<p>一个入口按 <b>profile</b> 分流:CI 每 push 只跑快而稳的 <code>fast</code>;视觉与 E2E 按需触发。</p>
<div class="fig">
<p class="cap">图 4 · 单一入口 → 三 profile 泳道</p>
<svg viewBox="0 0 860 270" role="img" aria-label="编排与 CI 流水线图">
<!-- entry -->
<rect x="10" y="105" width="150" height="58" rx="10" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="85" y="130" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">单一入口</text>
<text x="85" y="147" text-anchor="middle" fill="var(--fg2)" font-size="11">make test / test.sh</text>
<!-- branch lines -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="none">
<path d="M160,120 C195,120 195,48 230,48"/>
<path d="M160,134 L230,134"/>
<path d="M160,148 C195,148 195,220 230,220"/>
</g>
<!-- lane fast -->
<rect x="230" y="22" width="300" height="52" rx="9" fill="rgba(94,194,122,.10)" stroke="var(--ok)" stroke-width="1.5"/>
<text x="246" y="42" fill="var(--ok)" font-size="12.5" font-weight="700">fast</text>
<text x="246" y="60" fill="var(--fg2)" font-size="11">L0 + L1 + L2(假桥/真库)</text>
<rect x="556" y="22" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="42" fill="var(--fg)" font-size="11.5" font-weight="700">触发:每次 pushnas CI</text>
<text x="570" y="60" fill="var(--fg2)" font-size="11">产物:通过/失败 + lcov 覆盖率</text>
<!-- lane visual -->
<rect x="230" y="108" width="300" height="52" rx="9" fill="rgba(224,136,79,.10)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="246" y="128" fill="var(--accent)" font-size="12.5" font-weight="700">visual</text>
<text x="246" y="146" fill="var(--fg2)" font-size="11">L3 golden + pixelmatch</text>
<rect x="556" y="108" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="128" fill="var(--fg)" font-size="11.5" font-weight="700">触发:push 或本地</text>
<text x="570" y="146" fill="var(--fg2)" font-size="11">产物:pixel diff 报告(HTML</text>
<!-- lane e2e -->
<rect x="230" y="194" width="300" height="52" rx="9" fill="rgba(224,106,106,.10)" stroke="var(--bad)" stroke-width="1.5"/>
<text x="246" y="214" fill="var(--bad)" font-size="12.5" font-weight="700">e2e</text>
<text x="246" y="232" fill="var(--fg2)" font-size="11">L4 真链路冒烟</text>
<rect x="556" y="194" width="294" height="52" rx="9" fill="var(--panel)" stroke="var(--border)" stroke-width="1"/>
<text x="570" y="214" fill="var(--fg)" font-size="11.5" font-weight="700">触发:本地 / 按需(docker±Mac</text>
<text x="570" y="232" fill="var(--fg2)" font-size="11">产物:数值容差报告</text>
<!-- arrows lane->product -->
<g stroke="var(--fg2)" stroke-width="1.5" fill="var(--fg2)">
<line x1="530" y1="48" x2="552" y2="48"/><polygon points="556,48 546,44 546,52"/>
<line x1="530" y1="134" x2="552" y2="134"/><polygon points="556,134 546,130 546,138"/>
<line x1="530" y1="220" x2="552" y2="220"/><polygon points="556,220 546,216 546,224"/>
</g>
</svg>
</div>
<h2>⑥ L4 端到端冒烟拓扑</h2>
<div class="fig">
<p class="cap">图 5 · docker-compose 真链路 + 测试驱动器(推荐边界 a)</p>
<svg viewBox="0 0 860 280" role="img" aria-label="端到端冒烟拓扑图">
<!-- driver -->
<rect x="10" y="60" width="170" height="160" rx="10" fill="var(--panel2)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="95" y="84" text-anchor="middle" fill="var(--fg)" font-size="13" font-weight="700">测试驱动器</text>
<g font-size="11" fill="var(--fg2)">
<text x="26" y="110">① 登陆 /v1/auth/login</text>
<text x="26" y="134">② 发起连接(真凭证)</text>
<text x="26" y="158">③ 发真流量(curl/iperf</text>
<text x="26" y="182">④ 拉 /v1/usage</text>
<text x="26" y="206" fill="var(--accent2)" font-weight="700">⑤ 断言 bytes↑ / 限流</text>
</g>
<!-- compose container -->
<rect x="250" y="28" width="600" height="224" rx="12" fill="none" stroke="var(--border)" stroke-width="1.5" stroke-dasharray="6 4"/>
<text x="266" y="48" fill="var(--fg2)" font-size="11.5" font-weight="700">docker-compose · 一台 Linux runner</text>
<g font-size="12" font-weight="700">
<rect x="276" y="70" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="356" y="93" text-anchor="middle" fill="var(--fg)">控制面 :8080</text>
<text x="356" y="110" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">HTTP + gRPC :9443</text>
<rect x="276" y="160" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--ok)" stroke-width="1.5"/>
<text x="356" y="183" text-anchor="middle" fill="var(--fg)">SQLite + Redis</text>
<text x="356" y="200" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">记账存储</text>
<rect x="500" y="70" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent2)" stroke-width="1.5"/>
<text x="580" y="93" text-anchor="middle" fill="var(--fg)">agent</text>
<text x="580" y="110" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">mTLS enroll · 上报</text>
<rect x="500" y="160" width="160" height="56" rx="9" fill="var(--panel)" stroke="var(--accent)" stroke-width="1.5"/>
<text x="580" y="183" text-anchor="middle" fill="var(--fg)">sing-box</text>
<text x="580" y="200" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">数据面出网</text>
<rect x="700" y="115" width="130" height="56" rx="9" fill="var(--panel)" stroke="var(--warn)" stroke-width="1.5"/>
<text x="765" y="138" text-anchor="middle" fill="var(--fg)" font-size="11.5">回环出口</text>
<text x="765" y="155" text-anchor="middle" fill="var(--fg2)" font-size="10.5" font-weight="400">httpbin/echo</text>
</g>
<!-- internal links -->
<g stroke="var(--fg2)" stroke-width="1.4" fill="var(--fg2)">
<line x1="356" y1="126" x2="356" y2="160"/><polygon points="356,160 352,150 360,150"/>
<line x1="500" y1="98" x2="436" y2="98"/><polygon points="436,98 446,94 446,102"/>
<line x1="580" y1="126" x2="580" y2="160"/><polygon points="580,160 576,150 584,150"/>
<line x1="700" y1="143" x2="664" y2="180" stroke-dasharray="4 3"/>
</g>
<!-- driver -> control plane -->
<g stroke="var(--accent)" stroke-width="1.6" fill="var(--accent)">
<line x1="180" y1="110" x2="274" y2="96"/><polygon points="276,96 266,92 266,101"/>
</g>
<text x="200" y="92" fill="var(--accent)" font-size="10">① ② ④</text>
<!-- driver traffic to data plane -->
<g stroke="var(--accent2)" stroke-width="1.4" fill="var(--accent2)" stroke-dasharray="5 3">
<line x1="180" y1="175" x2="498" y2="188"/>
</g>
<text x="300" y="245" fill="var(--accent2)" font-size="10">③ 真流量经数据面出网 → 计数</text>
</svg>
</div>
<h2>⑦ 量化指标总表</h2>
<table>
<thead><tr><th style="width:24%">维度</th><th style="width:30%">指标</th><th style="width:46%">工具 / 口径</th></tr></thead>
<tbody>
<tr><td>覆盖率</td><td>行/分支 %</td><td><code>flutter test --coverage</code> + <code>go test -coverprofile</code> → 合并 lcov</td></tr>
<tr><td>视觉</td><td>不一致像素比例 vs 阈值 0.05</td><td>pixelmatchdiff.mjs);golden 通过/失败</td></tr>
<tr><td>数值精度</td><td>实测 vs 期望容差</td><td>L4 断言 bytes/速率/累计落在容差带内</td></tr>
<tr><td>时延</td><td>毫秒 ms</td><td>urltest 延迟 &gt;0 且在合理区间</td></tr>
<tr><td>稳定性</td><td>通过率 / flaky 率</td><td>多次重跑统计;flaky 用例隔离</td></tr>
</tbody>
</table>
<h2>⑧ 可复用与可自定义</h2>
<p><b>跨项目模板(复制即用,按接缝微调)</b></p>
<ul>
<li>假桥模式(<code>VpnBridge</code> 接口 + Mock)→ 任何「原生/外部依赖」都可照此切假。</li>
<li>ProviderScope/DI 覆盖注入 → 任何状态层都可注入假数据。</li>
<li>golden + 字体锁定(<code>flutter_test_config.dart</code>)→ 视觉回归通用骨架。</li>
<li>Go 内存真库套(<code>run_sqlite_test.sh</code>+ testcontainers 集成套 → 任何 DB 项目通用。</li>
<li>E2E docker-compose 骨架 + 探针驱动器 → 换服务清单即复用。</li>
<li>单一编排器 + profile 分流 + 合并报告 → 框架的「壳」,跨项目不变。</li>
</ul>
<p><b>每项目按接缝定制</b>:契约字段、API 路径、compose 服务清单、golden 屏清单、数值容差阈值。</p>
<h2>⑨ 落地路线(建议)</h2>
<table>
<thead><tr><th style="width:8%"></th><th style="width:42%">动作</th><th style="width:24%">性价比</th><th style="width:26%">起点</th></tr></thead>
<tbody>
<tr><td>1</td><td><b>L0 契约快照</b> + 把现有 <b>golden 接进 CI</b></td><td><span class="tag t-ok">最高(防回归)</span></td><td>素材已有,组装即可</td></tr>
<tr><td>2</td><td>组装 <b>L2 前端 flow 测试</b>(登陆/续登/连接/登出)</td><td><span class="tag t-ok"></span></td><td>Mock/覆盖素材全齐</td></tr>
<tr><td>3</td><td><b>L4 后端全链路冒烟 (a)</b></td><td><span class="tag t-warn">中(覆盖"真不真"</span></td><td>需新建 compose + 驱动器</td></tr>
<tr><td>4</td><td>补 L1 数值单测 + L2 HTTP 鉴权测试</td><td><span class="tag t-warn"></span></td><td>增量补齐</td></tr>
<tr><td>5</td><td>(b) desktop 真连 / (c) 真机——本地按需</td><td><span class="tag t-bad">低(贵·脆)</span></td><td>需常驻 Mac / 真机</td></tr>
</tbody>
</table>
<p class="note">本文档仅为架构说明(设计真相源)。具体落地拆解(契约快照实现、前端 flow 用例、L4 冒烟脚本)走各自实现计划(writing-plans)。</p>
<h2>⑩ 已知缺口 / 测试盲区</h2>
<div class="lead">
框架不是「测了什么」的清单,更要诚实地记「<b>没测什么、为什么、何时补</b>」——否则盲区会散在对话与脑子里被遗忘。本节是这些缺口的<b>单一跟踪源</b>,补上一项就划掉一项。
</div>
<h3>A. 覆盖盲区(功能上没被验证到的部分)</h3>
<table>
<thead><tr><th style="width:22%">缺口</th><th style="width:12%">严重度</th><th style="width:40%">现状 &amp; 为什么没测</th><th style="width:26%">何时 / 怎么补</th></tr></thead>
<tbody>
<tr>
<td><b>前端统计「上屏数值对不对」</b></td>
<td><span class="tag t-warn">中·诉求半覆盖</span></td>
<td>最初诉求是「统计数据对不对」。后端记账已 L4 e2e(<code>ReportUsage → /v1/usage</code> 断言字节);前端只到单测(<code>device_usage_test</code>/<code>format_test</code> 解析+格式化)。<b>解析对 ≠ 渲染对</b>——没有测试验证统计页拿到真实响应后上屏的数字/曲线是对的。</td>
<td>可立即补:widget 级测试,用 e2e server 的真实 <code>/v1/usage</code> 响应喂 <code>stats_page</code>,断言关键数值/曲线上屏。闭合诉求最后一环。</td>
</tr>
<tr>
<td><b>真实流量数据路径</b></td>
<td><span class="tag t-bad">高·最大盲区</span></td>
<td>L4 e2e 是<b>注入</b><code>ReportUsage</code>,不是「真 sing-box 出网 → v2ray stats 采集 → 上报记账」。真链路从未自动跑过——这是「我们其实不知道线上准不准」的根盲区。真出网那环仅 <code>usage_v2ray.go</code> 单测覆盖采集解析。</td>
<td>贵且脆(单机 docker 真出网重、CI 跑不动)。留后续按需,或本地手动跑一次真连基准。</td>
</tr>
<tr>
<td><b>原生隧道层</b></td>
<td><span class="tag t-bad"></span></td>
<td>真正承载流量的代码——<code>client/ios/Runner/VpnManager.swift</code><code>client/android/…/PangolinVpnService.kt</code>、macOS 系统扩展——<b>零自动化测试</b>,全靠真机/模拟器手测。</td>
<td>依赖真机/模拟器,难纯 CI 化。至少补「连上→可达性探针→断开」的半自动脚本。</td>
</tr>
<tr>
<td><b>契约双份可能各自漂移</b></td>
<td><span class="tag t-warn"></span></td>
<td>Dart 与 Go 各冻一份字段面快照,两边是各自独立的真相,理论上可各自漂移(OpenAPI 改了、只更了一边)。</td>
<td>真·单源(从 OpenAPI 生成两端契约)是大改,列入 C 类效率项后续做。</td>
</tr>
</tbody>
</table>
<h3>B. 运维 / 工程缺口(能跑,但脆或欠账)</h3>
<table>
<thead><tr><th style="width:22%">缺口</th><th style="width:12%">严重度</th><th style="width:40%">现状 &amp; 为什么</th><th style="width:26%">何时 / 怎么补</th></tr></thead>
<tbody>
<tr>
<td><b>CI 是单点</b></td>
<td><span class="tag t-warn">中·运维</span></td>
<td>整条 CI 吊在一台 macmac-pangolin-2+ 依赖 jiu 的 relay + Docker Desktop。<b>launchd 持久化尚未激活</b>(见 <code>docs/ci-runner.md</code> 的一次性 <code>!</code> 命令)——mac 一关/重启 CI 就全停。</td>
<td>激活持久化(脚本已就位);理想归宿是 NAS Linux host runner。</td>
</tr>
<tr>
<td><b>go-integration job 脆性</b></td>
<td><span class="tag t-warn"></span></td>
<td>跑在<b>裸宿主机</b>go1.26.1,非容器,testcontainers 要真 dockerDooD 在 Docker Desktop mac 网络不通)——宿主 go 版本漂移即挂;套件 ~5min;曾踩 ryuk 关闭→容器泄漏坑(已用默认 ryuk + <code>-p 1</code> 串行规避)。</td>
<td>迁 NAS Linux host runner 可恢复容器化 hermetic;保持 ryuk 开启。</td>
</tr>
<tr>
<td><b>analyze 严格化(闸·B1</b></td>
<td><span class="tag t-warn">中·阻塞</span></td>
<td>去掉 <code>--no-fatal-infos</code> 让 info 变致命,会因 HEAD <code>stats_page.dart</code><code>withOpacity</code> info 当场红;而清 info 正动 stats-overhaul 在改的同一文件。</td>
<td><b>被 stats-overhaul 耦合阻塞</b>,其合并后立刻做。</td>
</tr>
<tr>
<td><b>golden 全集(闸·B2</b></td>
<td><span class="tag t-warn">中·阻塞</span></td>
<td>golden job 现只跑 components + authtablet/desktop-stats golden 与 <code>tablet_pages_golden_test.dart</code> 全是 dirty WIP,并入等于钉死未定稿基线。</td>
<td>stats-overhaul 合并后并入;连带补回临时摘除的 DeviceUsage 契约快照(Dart+Go+ e2e <code>/v1/usage/devices</code> 断言。</td>
</tr>
<tr>
<td><b>golden 本地难重生</b></td>
<td><span class="tag t-ok">低·摩擦</span></td>
<td>Linux 权威基线(mac 渲染不一致),开发机改 UI 后须 docker 起 flutter 容器重生基线,非纯本地。</td>
<td><code>scripts/update-goldens.sh</code> 已封装,文档化即可,非真缺口。</td>
</tr>
</tbody>
</table>
<p class="note">维护约定:补上某项缺口后,从本表删去对应行(或标 <span class="tag t-ok">已补</span>);新发现的盲区即时登记到此,使本节始终等于「当前真实缺口」。</p>
</div>
</body>
</html>