55d368d98b
把散在对话里的盲区收进架构文档,作为单一跟踪源(补一项划一项): - 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>
506 lines
34 KiB
HTML
506 lines
34 KiB
HTML
<!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 增长、延迟 >0</td>
|
||
<td>实测 vs 期望<b>容差</b>;时延 ms;速率 B/s >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>L2:SQLite 真库 + 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 入库、限流触发、实时数值 >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">触发:每次 push(nas 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>pixelmatch(diff.mjs);golden 通过/失败</td></tr>
|
||
<tr><td>数值精度</td><td>实测 vs 期望容差</td><td>L4 断言 bytes/速率/累计落在容差带内</td></tr>
|
||
<tr><td>时延</td><td>毫秒 ms</td><td>urltest 延迟 >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%">现状 & 为什么没测</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%">现状 & 为什么</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 吊在一台 mac(mac-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 要真 docker,DooD 在 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 + auth;tablet/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>
|