Files
pangolin/docs/test-architecture.html
T
wangjia c5223af4a1 test(ci): L3 golden 权威环境脚本(进 CI 前置准备)
golden 真进 CI 卡两个硬约束:Linux/mac 渲染不一致(mac 基线在 CI 全红)+ 基线绑定
stats-overhaul dirty 工作区。本提交备好权威环境,不裹挟 dirty、不破坏:
- scripts/update-goldens.sh:在 CI 同款镜像 cirruslabs/flutter 重生成 Linux 权威基线
  (开发者改 UI 后用它刷新,而非裸 mac --update-goldens,保持与 CI 一致)
- docs/test-architecture.html:L3 现状更新 + 列明启 CI 硬闸的两个前置条件
- 真启闸需:有 docker 的机器生成并提交 Linux 基线 + dirty 工作区合并(本机无 docker)

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

432 lines
29 KiB
HTML
Raw 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">20+ golden 基准已有</span> <span class="tag t-warn">权威环境脚本 <code>scripts/update-goldens.sh</code> 已备</span> <span class="tag t-bad">CI 硬闸未启</span>。启用前置:① 有 docker 的机器跑 <code>update-goldens.sh</code> 提交一套 Linux 权威基线(mac 基线在 CI 必全红)② stats-overhaul dirty 工作区合并、基线落定 → 再加 <code>flutter test test/golden</code> CI 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>
</div>
</body>
</html>