测试
源码文件:65 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/Cargo.tomlEtemenanki/protocols/Cargo.tomlEtemenanki/app/Cargo.tomlEtemenanki/concepts/src/runtime.rsEtemenanki/concepts/src/client.rsEtemenanki/concepts/src/link.rsEtemenanki/concepts/tests/runtime.rsEtemenanki/concepts/tests/client.rsEtemenanki/environment/src/lib.rsEtemenanki/environment/tests/integration.rsEtemenanki/environment/tests/integration/udp.rsEtemenanki/environment/tests/unit/dial/socket.rsEtemenanki/protocols/src/lib.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/core/harness.rsEtemenanki/protocols/tests/pipeline.rsEtemenanki/protocols/tests/pipeline/core.rsEtemenanki/protocols/tests/pipeline/trojan.rsEtemenanki/protocols/tests/pipeline/vless.rsEtemenanki/protocols/tests/pipeline/vmess.rsEtemenanki/protocols/tests/pipeline/shadowsocks.rsEtemenanki/protocols/tests/pipeline/transports.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/pipeline/hysteria.rsEtemenanki/protocols/tests/pipeline/wireguard.rsEtemenanki/protocols/tests/pipeline/tun.rsEtemenanki/protocols/tests/support/mod.rsEtemenanki/protocols/tests/support/pipeline.rsEtemenanki/protocols/tests/support/wireguard.rsEtemenanki/protocols/tests/unit/core/mod.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/tests/unit/socks/server.rsEtemenanki/protocols/tests/unit/socks/protocol.rsEtemenanki/protocols/src/wireguard/device.rsEtemenanki/protocols/tests/unit/wireguard/device.rsEtemenanki/app/src/main.rsEtemenanki/app/tests/integration.rsEtemenanki/app/tests/support/mod.rsEtemenanki/app/tests/integration/e2e_xray.rsEtemenanki/app/tests/integration/e2e_xray_vmess.rsEtemenanki/app/tests/integration/e2e_xray_mux.rsEtemenanki/app/tests/integration/e2e_unix.rsEtemenanki/app/tests/integration/e2e_hysteria.rsEtemenanki/app/tests/integration/e2e_hysteria_inbound.rsEtemenanki/app/tests/integration/e2e_wg.rsEtemenanki/app/tests/integration/e2e_tun.rskatana/Cargo.tomlkatana/src/main.rskatana/src/runtime.rskatana/src/serve.rskatana/src/traffic.rskatana/src/manager/mod.rskatana/src/manager/node.rskatana/tests/integration.rskatana/tests/support/mod.rskatana/tests/integration/xray_interop.rskatana/tests/integration/sniff.rskatana/tests/integration/hysteria_interop.rskatana/tests/unit/e2e.rskatana/tests/unit/runtime.rskatana/tests/unit/serve.rskatana/tests/unit/traffic.rskatana/tests/unit/meter.rskatana/tests/unit/api/newv2board.rs
两个仓库都按层次测试。最底层手动调用 sans-I/O 的协议核心或 codec,检查它产生的 effect 和字节。往上一层用玩具协议在内存管道上驱动每连接运行时。再往上,每个协议的服务端核心通过 loopback socket 与它自己的客户端 codec 对跑。最顶层以子进程方式运行真实的 etemenanki-app 和 katana 二进制,其中的互通测试会对接独立实现(Xray 以及官方 Hysteria 2 客户端和服务端),这些实现由测试用 Go 从参考源码树构建。
本页面向在任一仓库中新增或修改代码的贡献者,说明各类测试放在哪里、每一层提供什么 harness、如何只运行一部分测试、改动必须通过哪些门槛,以及新增协议或修改流水线时应该带上哪些测试。各组件页面列出了固定其行为的具体测试;本页讲的是这些测试是怎样搭建起来的。
单元测试放在 tests/unit/,编译进所属模块
Section titled “单元测试放在 tests/unit/,编译进所属模块”单元测试不写在被测文件里。测试代码放在 <crate>/tests/unit/ 下,路径与模块路径对应,由被测模块通过 #[path] 属性挂载:
#[cfg(test)]#[path = "../../tests/unit/trojan/core.rs"]mod tests;测试文件用 use super::*; 把父模块引入作用域,通常写在第一行。由于它作为被测代码的子模块编译,它能访问该模块可见的一切,包括私有项和模块私有的 use 导入(Trojan 的测试就是从 super::* 拿到 Validator、Arc 和 io),而这些都不必改成 pub。Cargo 只会把 tests/*.rs 和 tests/<dir>/main.rs 自动识别为集成测试目标,而 tests/unit/ 下没有 main.rs,所以这些文件只会通过 #[path] 挂载被编译。
| 规则 | 示例 |
|---|---|
| 路径通常与模块路径对应 | protocols/src/vless/codec.rs 挂载 tests/unit/vless/codec.rs;protocols/src/hysteria/server/inbound.rs 挂载 tests/unit/hysteria/server/inbound.rs |
| transports 把第三层压平成一个文件名 | protocols/src/transports/tls/config.rs 挂载 tests/unit/transports/tls_config.rs;transports/grpc/framing.rs 挂载 tests/unit/transports/grpc_framing.rs |
protocols 中的 mod.rs 挂载一个 mod.rs |
protocols/src/core/mod.rs 挂载 tests/unit/core/mod.rs |
二进制 crate 的 tests/unit/ 保持扁平 |
app/src/inbound/mod.rs 挂载 tests/unit/inbound.rs;katana 的 src/outbound/mod.rs 挂载 tests/unit/outbound.rs |
二进制 crate 可以从 main.rs 挂载一组测试 |
katana 的 src/main.rs 把 tests/unit/e2e.rs 挂载为 mod e2e |
唯一的例外是 etemenanki-concepts 中的三个文件:concepts/src/buffer.rs、concepts/src/wake.rs 和 concepts/src/relay.rs 保留了一个小的内联 mod tests。新测试一律放进 tests/unit/。
environment/src/lib.rs 和 protocols/src/lib.rs 在整个 crate 范围内 deny clippy::unwrap_used、clippy::expect_used、clippy::indexing_slicing 和 clippy::arithmetic_side_effects,再在 cfg_attr(test, …) 下把这四项全部 allow。挂载的单元测试在 cfg(test) 下属于 crate 本身,正是这条 allow 让它们可以随意 unwrap() 和下标访问。集成测试目标是独立的 crate,根本看不到这条 deny。随库发布的测试支持代码(例如 CoreHarness)不在 cfg(test) 下,因此要遵守这些 lint:这就是它用 get(..).unwrap_or_default() 和 saturating_add,而不用下标访问和 + 的原因。
katana 没有 crate 级的 deny。它在源码中用 #[cfg(test)] 限定了几个仅供测试使用的辅助函数:src/traffic.rs 中的 AuthKey::from_auth、TokenBucket::consume、NodeTraffic::set_users 和 NodeTraffic::get。生产代码把 NodeTraffic::prepare 和 NodeTraffic::commit 作为两个独立步骤调用,并直接调用 TokenBucket::charge。
集成测试目标
Section titled “集成测试目标”每个 package 最多有两个集成测试入口。入口是 tests/ 下的单个文件,由它声明子模块,这样同一类场景全部链接进一个测试二进制,而不是每个文件一个二进制。
| Package | 入口 | 模块 | 证明什么 |
|---|---|---|---|
etemenanki-concepts |
tests/runtime.rs |
无 | 由玩具核心驱动的 ProxyServerRuntime:中继、背压、deadline、数据报传输层、held 字节 |
etemenanki-concepts |
tests/client.rs |
server_side |
由玩具 codec 驱动的 ProxyClientRuntime,单独运行,以及作为服务端运行时的出站 |
etemenanki-environment |
tests/integration.rs |
tcp、udp、quic(feature quic) |
拨号器对接真实的 loopback 对端 |
etemenanki-protocols |
tests/pipeline.rs |
pipeline::{core, dns_secure, http, hysteria, shadowsocks, socks, transports, trojan, tun, vless, vmess, wireguard} |
每个服务端核心通过 loopback 对接其客户端 codec;每种传输层;DNS over TLS 和 HTTPS;WireGuard connector 对接进程内对端 |
etemenanki-app |
tests/integration.rs |
13 个 e2e_* 模块 |
真实二进制:与 Xray 和 Hysteria 互通、路由、嗅探、DNS、负载均衡器、Unix socket、WireGuard、TUN |
katana |
tests/integration.rs |
xray_interop、sniff、hysteria_interop |
真实二进制对接假面板,由真实的 Xray 和 Hysteria 客户端驱动 |
在 tests/pipeline.rs 中,mod hysteria 位于 #[cfg(feature = "hysteria")] 之后,mod tun 位于 #[cfg(feature = "tun")] 之后。在 environment/tests/integration.rs 中,mod quic 位于 #[cfg(feature = "quic")] 之后。etemenanki-protocols、etemenanki-app 和 katana 的共享辅助代码放在 tests/support/,由入口文件以 mod support; 声明。protocols/tests/support/mod.rs 和 support/wireguard.rs 带有 #![allow(dead_code)],因为没有哪个场景会用到全部辅助函数。两个 concepts 目标和 environment 目标没有共享的 support 模块。
katana 的进程内端到端测试不是集成测试目标。tests/unit/e2e.rs 从 src/main.rs 挂载进二进制 crate,因此它和单元测试一起运行,并能以 crate 私有权限访问 NodeManager、PanelClient 和各配置结构体。它的测试夹具是 pub(crate) 的,从 src/runtime.rs 挂载的 tests/unit/runtime.rs 从 crate::e2e 导入它们,以对运行中的节点执行重载。
| 层 | 位置 | I/O | 在这一层失败通常意味着 |
|---|---|---|---|
| Codec 与解析器单元测试 | protocols/tests/unit/<proto>/{protocol,codec}.rs |
无 | 线上字节错误,或接受了格式错误的字段 |
| 核心单元测试 | protocols/tests/unit/<proto>/core.rs,通过 CoreHarness |
无 | effect 错误:过早的 Open、缺失的 deadline、丢失的半关闭 |
| 运行时测试 | concepts/tests/{runtime,client}.rs |
tokio::io::duplex,部分使用 loopback 上的 UDP |
驱动器破坏了事件约定、背压或 deadline |
| 流水线测试 | protocols/tests/pipeline/*.rs |
loopback TCP 和 UDP | 服务端核心与客户端 codec 不一致,或某个传输层坏了 |
| App 单元测试 | app/tests/unit/*.rs |
基本没有 | 接受了本应 fail closed(出错即拒绝)的配置,或某个构建步骤接错了 |
| App 端到端测试 | app/tests/integration/e2e_*.rs |
loopback 上的子进程 | 运维实际运行的二进制不能工作,或与 Xray、Hysteria 不一致 |
| katana 单元测试 | katana/tests/unit/*.rs |
duplex,暂停时钟 |
准入、计量、速率限制或计费逻辑 |
| katana 进程内 e2e | katana/tests/unit/e2e.rs、katana/tests/unit/runtime.rs |
loopback,进程内假面板 | 节点管理器这一整棵树:同步、计量、上报、用户刷新、启动重试、重载决策 |
| katana 集成测试 | katana/tests/integration/*.rs |
loopback 上的子进程 | 二进制对接独立客户端和面板 |
运行时测试:玩具核心与玩具 codec
Section titled “运行时测试:玩具核心与玩具 codec”concepts/tests/runtime.rs 和 concepts/tests/client.rs 从不使用真实协议。它们定义的小协议,整个线上格式一段注释就能写完,这样失败时指向的是运行时而不是某个 codec。etemenanki-concepts 在 dev-dependencies 中为暂停时钟测试启用了 tokio 的 test-util。
服务端玩具核心
Section titled “服务端玩具核心”| 核心 | 传输层 | 线上格式 | 用途 |
|---|---|---|---|
TinyMux |
字节流 | [kind][key][len:u16][payload],payload 与 0x55 异或;客户端发出的 kind 为 OPEN 1、DATA 2、CLOSE 3、FIN 4,发往客户端的为 CONNECTED 5、CONNECT_FAILED 6、EOF 7 |
一条连接上的多个 key:中继、停滞、半关闭、deadline、FrameTooLarge |
TinyUdp |
字节流 | [len:u16][port:u16][payload];len == 0 表示结束;len == 0xFFFE 通过 IPv4 socket 向 IPv6 对端发送,以触发 SendFailed |
Key = Single 配合数据报出站 |
TinyHub |
UDP socket(over_datagrams) |
每个包为 [port:u16][payload];port 0 表示结束;port 0xFFFF 触发 TransportSendFailed |
Key = SocketAddr:每个对端一个出站,回复经 stage_to 发出 |
TinySessions over TinyQuic |
单对端 DatagramLink(Addr = ()),基于两个 mpsc 通道,拒收超过 max 的包 |
[session:u32][port:u16][payload] |
QUIC 数据报的形态:一个对端、多个会话,被拒收的包以 NOTICE 报告 |
Sniffing |
字节流 | 前 8 个字节是目标名 | Effect::ForwardHeld 和 held 缓冲区的固定规则 |
各核心的常量是为测试选定的:TinyMux 的 STAGING_RESERVE = HDR + 64(其中 HDR = 4),TinyUdp 为 4,TinyHub 为 2,TinySessions 为 6,Sniffing 为 0。
拨号器就是普通闭包,因为 Connector 为 FnMut(Target) -> Future 提供了 blanket 实现:
dialer(dials)为每个Open弹出一个预先建好的DuplexStream,并以ConnectionRefused("refused by test")拒绝目标"fail"。mux_harness(far_caps)建立客户端管道(4096 字节),并为far_caps的每一项建立一个远端 duplex。测试正是用小容量让某个出站停滞:stalled_outbound_holds_uplink_but_not_other_downlink给 key 1 分配了一个 8 字节的管道。gated_dialer(targets, links)记录每个目标,只有在测试触发一个oneshot后才完成拨号,这样测试可以在更多字节到达时让拨号保持挂起。udp_connector和udp_echo在127.0.0.1上绑定真实的 UDP socket。
core_is_driven_without_any_io 是唯一完全不涉及运行时的测试:它用一个 EffectList 和一个 WriteBuffer::<256> 调用 TinyMux::handle,然后对 effect 做断言。CoreHarness 推广的正是这个模式。
客户端玩具 codec
Section titled “客户端玩具 codec”| Codec | Trait | 线上格式 | 用途 |
|---|---|---|---|
TinyCodec |
ProxyCoreEncodeHandshake、ProxyCoreEncode |
头部 [0xC0][len:u8][target],之后是 [kind][len:u16][payload ^ 0x55],kind 1 为数据、2 为关闭;seal 每次最多取 MAX_FRAME = 32 字节 |
跨调用的分帧、EOF、拨号失败、背压 |
TwoRoundCodec |
同上 | 仿 SOCKS:先 [0x05] 再 [0x05, 0x00],先 [0x01][target] 再 [0x00] |
Handshake::AwaitReply 与被拒绝的认证方法 |
Scripted |
全部三个,包括 ProxyCoreEncodeDatagram |
返回测试设定的任意 Handshake、Reply 和 Opened |
违反约定:每个这类测试都给读取套上 1 秒超时,并期望得到错误,而绝不是空转 |
TinyUdpCodec |
ProxyCoreEncodeHandshake、ProxyCoreEncodeDatagram |
TinyCodec 分帧,每个 payload 前加 [port:u16] |
基于流式上游的数据报 |
server_side 模块运行一个服务端运行时,其 connector 是包装了 TinyCodec 的 ProxyClientConnector,核心为 Passthrough,收到 Connected 时回复 +,收到 ConnectFailed 时回复 -。它固定了两点:只有上游接受了头部之后才报告 Connected(server_runtime_relays_through_a_client_runtime_in_one_task 用一个 4 字节的上游管道让头部等待);拨号失败或上游握手被拒都会变成 ConnectFailed(upstream_dial_failure_is_connect_failed_not_connected、refused_upstream_handshake_is_connect_failed_not_connected)。
在测试中选择 BUF_SIZE
Section titled “在测试中选择 BUF_SIZE”两个运行时在构造时都会检查缓冲区与核心或 codec 是否匹配:
assert!( BUF_SIZE > Core::STAGING_RESERVE, "BUF_SIZE must exceed the core's STAGING_RESERVE or nothing is ever read");ProxyClientRuntime::new 对 Codec::STAGING_RESERVE 做同样的检查。因此,为了制造某种情况而选用极小缓冲区的测试,如果选得太小,会在构造时 panic。frame_larger_than_the_buffer_is_an_error 用 BUF_SIZE = 128 配合一个 504 字节的帧,合法地触发 RuntimeError::FrameTooLarge。
核心测试:CoreHarness
Section titled “核心测试:CoreHarness”CoreHarness 手动驱动一个服务端核心:投递事件,收集核心推送的 effect 和它暂存的字节,不做任何 I/O。它放在库里而不是 cfg(test) 下,所以 crate 自己的单元测试和 downstream crate 都能使用。
pub const HARNESS_STAGING: usize = 64 * 1024;
pub struct CoreHarness<C: ProxyCoreDecode> { pub core: C, /* private: effects, staging, packets */}
impl<C: ProxyCoreDecode> CoreHarness<C> { pub fn new(core: C) -> Self; pub fn over_datagrams(core: C) -> Self; pub fn event(&mut self, event: Event<'_, C>) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn transport(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn feed(&mut self, data: &mut [u8]) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn outbound( &mut self, key: C::Key, data: &mut [u8], ) -> Result<(usize, Vec<Effect<C>>), C::Error>; pub fn staged(&mut self) -> Vec<u8>; pub fn staged_packets(&mut self) -> Vec<(Vec<u8>, C::TransportAddr)>; pub fn held(&self, range: std::ops::Range<usize>) -> Vec<u8>;}| 方法 | 行为 |
|---|---|
new |
模拟一个流式传输层。暂存区是一个 WriteBuffer<HARNESS_STAGING>,64 KiB。 |
over_datagrams |
额外维护一个 PacketList,使核心的 stage_to 和 put_to 可用。用于 TransportAddr 是对端地址的核心。 |
event |
用一个新的 Effects sink 调用一次 core.handle,按顺序返回消耗的字节数和推送的全部 effect。 |
transport |
即 event(Event::Transport(data)):一次调用,原样传入。 |
feed |
模拟运行时的做法:只要核心有进展,就对未消耗的剩余部分继续调用,某次调用消耗 0 字节时停止。Effect::Forward 和 Effect::SendTo 的范围会被重新定位,使其成为相对 data 的绝对范围。 |
outbound |
即 event(Event::Outbound { key, data })。 |
staged |
取出到目前为止暂存、准备发往传输层的全部数据,并清空。 |
staged_packets |
按顺序取出暂存的包,每个包带有其对端地址。在 new 下为空。 |
held |
像运行时那样,把一个 ForwardHeld 范围对照 core.held() 解析出来。 |
transport 和 feed 的区分是有意为之。transport 测试核心对恰好给定的字节做什么,header_split_across_events_opens_once_complete(protocols/tests/unit/trojan/core.rs)就是用它检查 Trojan 头部的 30 个字节消耗 0 字节、且只设置握手 deadline。feed 测试运行时执行的循环,多帧输入需要的正是它。
大多数核心测试文件会在 harness 之上定义两个辅助函数:一个构造函数,例如 core(sniff: bool) -> CoreHarness<TrojanCore<()>>;以及 moves(fx),它丢弃 Effect::SetDeadline,让断言只关注行为。当 deadline 本身就是测试重点时,直接用具名常量对它断言:protocols/src/core/mod.rs 中的 HANDSHAKE_TIMEOUT(10 秒)、RELAY_IDLE_TIMEOUT(300 秒),以及 protocols/src/sniff/mod.rs 中的 SNIFF_TIMEOUT(300 毫秒)。deadline 测试通过 event 投递 Event::Deadline,不涉及任何时钟。
protocols/tests/unit/core/mod.rs 测试各核心组合使用的共享组件(Timing、SniffPrefix、Passthrough、PassthroughCore)。其中大多数测试不用 harness,而是用一个 with_sink 辅助函数:它在 WriteBuffer::<256> 上构造一个 Effects,返回排空后的 effect 和暂存字节;只有针对开启嗅探的 PassthroughCore 的三个测试使用 CoreHarness。
流水线测试:服务端核心对接客户端 codec
Section titled “流水线测试:服务端核心对接客户端 codec”protocols/tests/pipeline.rs 把每个协议的两半放在一条 loopback TCP 连接的两端:服务端核心跑在 ProxyServerRuntime 里,客户端 codec 跑在 ProxyClientRuntime 里,目标是一个 echo 服务器。两端都不知道对面是自己的兄弟实现,所以两者之间的任何分歧都会表现为字节损坏或缺失。
sequenceDiagram participant T as 测试 participant C as 带 codec 的 ProxyClientRuntime participant L as serve_runtime 监听器 participant S as 带核心的 ProxyServerRuntime participant E as tcp_echo T->>C: client(codec, server addr) C->>L: TCP 连接到 127.0.0.1 L->>S: 每次 accept 启动一个运行时,make_core() T->>C: write_all(payload) C->>S: 握手头部,然后是 seal 后的帧 S->>E: Effect::Open,FreedomConnector 拨号 S->>E: Effect::Forward 解码后的字节 E-->>S: 回显的字节 S-->>C: 核心暂存的帧 C-->>T: read_exact 返回 payload T->>C: shutdown() C-->>T: read_to_end 为空
tests/support/pipeline.rs 中的辅助函数
Section titled “tests/support/pipeline.rs 中的辅助函数”pub struct FreedomConnector;
impl Connector<Flow<()>> for FreedomConnector { type Stream = TcpStream; type Datagram = UdpOutbound; type Future = DialFuture<TcpStream, UdpOutbound>; fn connect(&mut self, flow: Flow<()>) -> Self::Future;}
pub struct TcpConnector;
impl Connector<Destination> for TcpConnector { type Stream = TcpStream; type Datagram = NoDatagram; type Future = DialFuture<TcpStream, NoDatagram>; fn connect(&mut self, dest: Destination) -> Self::Future;}
pub async fn serve_runtime<const BUF: usize, Core, F>(make_core: F) -> SocketAddrwhere Core: ProxyCoreDecode<Target = Flow<()>, TransportAddr = ()> + Send + 'static, Core::Error: fmt::Display, F: Fn() -> Core + Send + Sync + 'static;
pub fn client<const BUF: usize, Codec>( codec: Codec, addr: SocketAddr,) -> ProxyClientRuntime<BUF, Codec, TcpConnector>where Codec: ProxyCoreEncodeHandshake<Target = Destination>, Codec::Error: std::error::Error + Send + Sync + 'static;| 辅助函数 | 行为 |
|---|---|
FreedomConnector |
直接拨号流的目标地址。UDP 流在 127.0.0.1:0 上绑定一个新 socket;TCP 流直接连接,指向域名的 TCP 流会以 Unsupported 失败,所以流水线测试用 IP 地址访问 echo 服务器。 |
TcpConnector |
通过明文 TCP 按地址拨号被测代理。域名地址会以 Unsupported 失败。 |
serve_runtime |
绑定 127.0.0.1:0,然后对每个接受的连接启动 ProxyServerRuntime::<BUF, Core, _, _>::new(stream, make_core(), FreedomConnector)。以错误结束的运行时会向 stderr 打印 server runtime ended with …;测试本身看到的错误表现为始终收不到的字节。 |
client |
即 ProxyClientRuntime::new(codec, &mut TcpConnector, tcp_dest(addr))。对流式 codec,结果是 AsyncRead + AsyncWrite;对数据报 codec,结果是 DatagramLink<Addr = Destination>。 |
tests/support/mod.rs 另外提供 tcp_echo() 和 udp_echo()(端口为 0 的 loopback echo 服务器)、用来构造 Destination 的 tcp_dest(addr) 和 udp_dest(addr),以及 self_signed_pem():一张 2048 位 RSA 证书,CN=localhost,带 localhost SubjectAltName,有效期 365 天,供 TLS 传输层和 DoT/DoH 使用。
BUF 参数取核心自己的常量,而不是随手猜的值:TrojanCore::<()>::BUF_SIZE(16 KiB)、VlessCore(16 KiB)、VMessCore(32 KiB)、ShadowsocksCore(20 KiB)、Ss2022Core(32 KiB)、HttpCore::<()>::BUF_SIZE(MAX_HEAD,64 KiB)、PassthroughCore(8 KiB)。使用生产环境的大小,能让测试走和 app 运行时相同的缓冲区算术。SOCKS 没有核心常量,它的流水线测试给客户端运行时固定使用 16 KiB。
不符合这一模式的协议
Section titled “不符合这一模式的协议”- SOCKS 有自己的驱动器而不是核心。
pipeline/socks.rs用serve_inbound(config, sniff)构建每个服务端:它接收完整的SocksServerConfig<()>,绑定127.0.0.1:0,并对每个接受的连接运行SocksInbound::serve(stream, local_ip, Some(peer.ip()), FreedomConnector)。new_server(auth)和new_server_with(auth, sniff)传给它的是config(auth):启用 UDP,不设置udp_bind,带auth时有一个账号alice/secret。需要其他udp_bind的 TCP 测试把自己的配置传给serve_inbound;Unix socket 测试则直接构建自己的SocksInbound。UDP 测试使用内核自己的客户端SocksUdpLink::associate;检查关联来源的测试会在它旁边再加一个发送方,或者手动驱动中继,详见下文。 - Hysteria 2 运行在 QUIC 上,所以
pipeline/hysteria.rs绑定一个 UDP socket,运行Hy2Inbound::run(socket, |_| FreedomConnector, token),并在NewServerguard 被 drop 时取消 token 来停止它。它只在启用hysteriafeature 时编译。 - TUN(
pipeline/tun.rs,featuretun)用一个 socketpair 代替设备:测试注入用etherparse构造的 IP 包,读取协议栈的应答,并用一个记录型 connector 捕获入站打开的流。它不需要特权,运行在flavor = "multi_thread"上。 - WireGuard 是出站,所以
pipeline/wireguard.rs让一个WgConnector指向下文介绍的进程内对端,并通过它驱动一个 TCP 流和一个 UDP 流。另外两个测试固定了隧道如何处理无法投递的流量:a_stalled_tcp_flow_blocks_its_writer(发往一个停止读取的远端的流会挡住其写入方,同一隧道中的另一条流仍能回显,远端恢复读取后写入方继续)和an_oversized_datagram_does_not_wedge_the_association(一个永远无法发出的超大数据报被丢弃,下一个数据报仍能回显)。 - 传输层(
pipeline/transports.rs)在每种InboundTransport后面提供一个 echo 服务,并用对应的TransportConnector拨号,覆盖固定 CA、WebSocket early data,以及一条连接上的多个 gRPC 流。
SOCKS 关联测试
Section titled “SOCKS 关联测试”在 TCP 上,一个 SOCKS5 UDP 关联只听一个客户端:即控制连接来自的那个 IP,按规范形式比较;它由转发的第一个数据报固定到一个端口,或者由一个指明该 IP 和端口的请求预先固定(见 SOCKS)。SocksUdpLink 总是声明全零的来源,所以需要指定来源或第二个发送方的测试绕过它,改用 pipeline/socks.rs 中的辅助函数:
| 辅助函数 | 行为 |
|---|---|
associate_raw(control, source) |
在任意 AsyncRead + AsyncWrite 流上发送无认证的问候和一个声明 source 的 UDP ASSOCIATE,并返回服务端的 Reply。读取回复时每次最多等待 5 秒。 |
send_via(socket, relay, target, payload) |
从一个普通的 UdpSocket 发送一个发往 target 的中继包。 |
recv_via(socket, relay) |
在 5 秒内返回下一个中继包的 payload,并断言它来自 relay。 |
recording_echo() |
位于 127.0.0.1 的 UDP echo,回显前把每个 payload 报告到一个无界 channel;forwarded(&mut seen) 取空该 channel,使测试能精确断言中继转发了什么。 |
assert_closed(control) |
期望服务端在 5 秒内关闭控制连接,拒绝之后服务端就会这样做。 |
发送不应被接受的数据报的测试,会把它排在客户端数据报之前,这样按顺序读取 socket 的中继会先遇到它。NOT_ALLOWED 为 0x02,是中继无法把关联限定到其客户端时的回复。
| 测试 | 固定的行为 |
|---|---|
udp_association_ignores_another_ip |
位于 127.0.0.2 的陌生方在客户端第一个数据报之前和之后各发送一次,都不会被转发,并且在测试等待的 200 毫秒内收不到任何回复 |
udp_association_ignores_another_port_once_pinned |
第一个数据报之后,127.0.0.1 上的第二个 socket 不会被转发 |
udp_association_holds_to_the_port_the_request_names |
请求指明客户端 socket 自己的地址和端口时,关联在任何数据报之前就限定到它:先发送的邻居不会被转发 |
udp_association_sets_aside_a_source_it_cannot_hold_to |
IPv4 客户端的请求声明 [::1]:0 时仍被批准,客户端可以中继 |
udp_association_is_not_widened_by_the_request |
请求指明 127.0.0.2 上的 socket 并不会放行它;控制连接的对端仍然可以中继 |
udp_association_over_a_unix_socket_needs_its_exact_source |
在 Unix socket 上,声明 0.0.0.0:0 的请求得到 0x02 并被关闭连接;指明客户端确切地址和端口的请求可以中继,并把邻居挡在外面 |
udp_association_refuses_a_relay_that_cannot_hear_the_client |
udp_bind = ::1 时,IPv4 客户端的关联得到 0x02 并被关闭连接 |
udp_link_ignores_datagrams_not_from_the_relay |
客户端一侧:手写的服务端批准关联,然后一个入侵者抢在中继之前回复客户端的第一个数据报;link 只返回中继的回复 |
udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay |
绑定在 [::]:0 上的 SocksUdpLink 接受 IPv4 中继的回复,这些回复来自其 IPv4 映射地址 |
Unix 测试为每个关联创建一个 UnixStream::pair(),一端作为控制连接,另一端用 serve(stream, None, None, FreedomConnector) 服务:没有本地 IP,也没有对端,所用配置把 udp_bind 设为 127.0.0.1。绑定 127.0.0.2 或双栈 socket 的测试(udp_association_ignores_another_ip、udp_association_is_not_widened_by_the_request、udp_link_on_a_dual_stack_socket_hears_an_ipv4_relay)标注了 #[cfg(target_os = "linux")],Unix 测试标注了 #[cfg(unix)]。在 Linux 上整个 127.0.0.0/8 都是 loopback,所以第二个发送方无需任何设置就能使用 127.0.0.2。
这条规则本身也有不用 socket 的测试。protocols/tests/unit/socks/server.rs 从 protocols/src/socks/server.rs 挂载,直接调用 ExpectedSender::new(peer, declared, hub)、admits 和 pin:第一次固定保持有效;IPv4 映射的对端视同其 IPv4 地址;客户端可能声明的来源(另一地址族的 loopback、NAT 之后的局域网地址、只有端口没有地址、域名、另一台主机)都被搁置;Unix socket 上的请求必须指明非零的 IP 地址和端口;中继绑定在客户端无法到达的地址族上是错误,而未指定的 IPv6 hub 能听到两个地址族。protocols/tests/unit/socks/protocol.rs 中的 endpoint_sees_through_ipv4_mapping_and_ignores_flow_info 固定了两端共用的比较方式。
Payload 大小
Section titled “Payload 大小”流式核心的 TCP 流水线测试会推送超过一个缓冲区容量的数据穿过隧道,使中继循环运转、暂存缓冲区回绕、背压生效:Trojan、VLESS、Shadowsocks 和 Shadowsocks 2022 为 70 000 字节,VMess、HTTP、SOCKS 和 PassthroughCore 为 100 000 字节。传输层测试回显 200 000 字节。Hysteria 2 的 TCP 测试使用短消息,转而检查流的共享。
UDP 测试先发送一个短数据报,再发送一个较大的:Trojan 和 VLESS 为 1500 字节,SOCKS 为 1400,VMess 为 4000,Hysteria 2 为 4096;后者超过 loopback 上任何 QUIC 数据报的大小,因此两个方向都会分片。SOCKS、Trojan、VLESS、VMess 和 Hysteria 2 的 UDP 测试还会断言第一个回复被归属到 echo 服务器的地址,而不只是字节一致。
进程内 WireGuard 对端
Section titled “进程内 WireGuard 对端”protocols/tests/support/wireguard.rs 在测试进程内运行一个完整的 WireGuard 对端:一个 boringtun Tunn 桥接到一个回显 TCP 和 UDP 的 smoltcp 接口,外加一个由测试开关其读取的 TCP sink。不需要内核接口、特权或网络访问。
pub const SERVER_TUN_IP: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 1);pub const CLIENT_TUN_IP: Ipv4Addr = Ipv4Addr::new(10, 0, 0, 2);pub const ECHO_PORT: u16 = 5555;pub const SINK_PORT: u16 = 5556;
pub async fn spawn_echo_peer( server_priv: [u8; 32], client_pub: [u8; 32], sink_reads: Arc<AtomicBool>,) -> SocketAddr;
pub struct Peer { pub endpoint: SocketAddr, pub client_private: [u8; 32], pub server_public: [u8; 32], pub sink_reads: Arc<AtomicBool>,}
pub async fn spawn_peer() -> Peer;sequenceDiagram participant W as 被测 WgConnector participant U as 对端 UDP socket participant N as boringtun Tunn participant D as TestDevice 的 rx 与 tx 队列 participant S as 10.0.0.1 上的 smoltcp socket W->>U: 加密包 U->>N: 将第 1 至 3 字节清零后 decapsulate N->>D: WriteToTunnelV4 推入 rx D->>S: iface.poll 投递 IP 包 S->>S: TCP 或 UDP echo 把数据复制回去,sink 不作任何应答 S->>D: 发出的包进入 tx 队列 D->>N: 逐个 encapsulate N->>U: WriteToNetwork U->>W: 加密的回复发往最近一个对端地址
spawn_echo_peer 中的循环依次执行:iface.poll,处理 TCP echo 监听器、TCP sink 和 UDP echo,加密 smoltcp 发出的全部数据,然后等待以下三者中最先发生的一个:收到一个包、200 毫秒的 update_timers tick,或 smoltcp 的 poll_delay。测试作者会碰到的细节:
| 细节 | 取值或行为 |
|---|---|
| 密钥 | spawn_peer 使用固定私钥 [0x11; 32](客户端)和 [0x22; 32](对端) |
| 隧道地址 | 对端为 SERVER_TUN_IP/24;客户端配置使用 CLIENT_TUN_IP |
| 设备 MTU | MTU = 1420 |
| 临时缓冲区 | SCRATCH = 64 KiB,用于 encapsulate 和接收 |
| 并发 TCP 连接 | LISTENERS = 4 个监听 ECHO_PORT 的 echo socket,每个在其连接关闭后重新监听 |
| TCP sink | SINK_PORT 上的一个 socket,接收和发送缓冲区各 64 KiB。在 sink_reads 被置位之前它什么也不读,因此其接收窗口会关闭,隧道也不再为写入它的一方排空数据;置位之后,它丢弃收到的一切。其连接关闭后重新监听 |
| Sink 开关 | spawn_peer 创建未置位的 sink_reads,并以 Peer::sink_reads 交给测试;对端在一个定时器 tick(200 毫秒)内察觉变化 |
| Socket 缓冲区 | TCP 每个方向 64 KiB;UDP 每个方向 16 个包、64 KiB |
| 保留字节 | 每个长度超过 3 字节的接收包,其第 1 至 3 字节会在 decapsulate 前清零,因此配置了 reserved 字节的客户端仍能完成握手 |
| 回复地址 | 对端回复最近收到的包的来源地址;在第一个包到达之前它什么也不发 |
流水线的 WireGuard 测试拨号这个对端。protocols crate 的测试支持代码不属于其库,所以 app/tests/integration/e2e_wg.rs 自带一份对端副本:一个 TCP 监听器,没有 UDP echo,没有 sink,密钥、MTU 和保留字节处理与原版相同。app_socks_to_wireguard_outbound_tcp 以 SOCKS 入站路由到 WireGuard 出站的配置启动真实二进制,并通过这个对端回显。
a_stalled_tcp_flow_blocks_its_writer 用 sink 固定穿过隧道的背压。它向 SINK_PORT 打开一条流,以 1 KiB 的块写入,每次写入限时 1 秒,直到某次写入不得不等待。sink 的 64 KiB 窗口、客户端 socket 的 64 KiB、驱动器 256 个块的通道,加上驱动器手中持有的一个块,合计 385 KiB;如果写入方开始等待之前这条流接受了超过 512 KiB,测试失败。随后,同一隧道中的第二条流必须在 10 秒内回显。测试置位 sink_reads 之后,原先在等待的写入必须在 10 秒内完成,再写 1024 个块必须在 20 秒内完成。an_oversized_datagram_does_not_wedge_the_association 先发送一个 70 000 字节的数据报,它大于驱动器给每个关联分配的 64 KiB 发送环,再发送一个短数据报,并期望在 10 秒内收回这个短数据报。
驱动器的上行队列还在不经隧道的情况下单独测试,位于 protocols/tests/unit/wireguard/device.rs(从 protocols/src/wireguard/device.rs 挂载):a_held_item_keeps_the_channel_unread 检查驱动器持有一个 socket 暂时放不下的块时,不会读取它后面的通道,因此生产者的 try_send 以 Full 失败;the_uplink_finishes_only_after_its_last_item 检查只有在应用发送的全部数据都已交出之后,上行才报告结束。
App 端到端测试
Section titled “App 端到端测试”app/tests/integration.rs 启动真实的 etemenanki-app 二进制。Cargo 会在同一 package 的集成测试目标之前构建该二进制,并通过 CARGO_BIN_EXE_etemenanki-app 暴露其路径;测试自己从不去 target/ 里找。
app/tests/support/mod.rs 中的支持代码
Section titled “app/tests/support/mod.rs 中的支持代码”pub struct Proc(Child);
impl Drop for Proc { fn drop(&mut self); }
impl Proc { #[cfg(unix)] pub fn terminate(mut self);}
pub fn spawn_app(dir: &Path, config: &str) -> Proc;pub fn spawn_xray(bin: &Path, dir: &Path, config: &str) -> Proc;pub fn spawn_hysteria(bin: &Path, dir: &Path, config: &str) -> Proc;pub fn spawn_hysteria_client(bin: &Path, dir: &Path, config: &str) -> Proc;
pub static XRAY_BIN: LazyLock<Option<PathBuf>>;pub static HYSTERIA_BIN: LazyLock<Option<PathBuf>>;
pub fn free_port() -> u16;pub fn free_udp_port() -> u16;pub async fn wait_port(port: u16) -> io::Result<()>;pub async fn wait_udp_port(port: u16) -> io::Result<()>;pub fn test_dir(name: &str) -> PathBuf;| 辅助函数 | 行为 |
|---|---|
Proc |
持有一个子进程。Drop 会杀掉并等待它,所以断言失败绝不会让监听器一直运行到下一个测试。terminate 通过 kill -TERM 发送 SIGTERM,最多等待 10 秒让进程退出,其余交给 Drop;当进程退出过程中的行为本身是测试对象时使用它(e2e_unix 检查 socket 文件被删除,e2e_tun 检查被路由的地址不再应答)。 |
spawn_app |
在 dir 中以 -c <config> 运行二进制,丢弃 stdout 和 stderr。 |
test_dir |
即 CARGO_TARGET_TMPDIR/<name>,不存在时创建。配置、证书和上游二进制都放在这里。运行结束后目录不会被删除,因此最后一次失败的配置仍可供检查。 |
free_port |
在 loopback 上绑定 TCP 端口 0,关闭 socket 后返回该端口。 |
free_udp_port |
UDP 版的同一功能。TCP 和 UDP 的端口空间相互独立,所以 QUIC 监听器必须用这个。 |
wait_port |
每 50 毫秒连接一次 127.0.0.1:<port>,最多 20 秒。 |
wait_udp_port |
每 50 毫秒尝试绑定一次该端口,最多 20 秒,一旦绑定以 AddrInUse 失败就返回,因为 UDP 监听器无法通过连接来探测。其他绑定错误会立即返回。 |
socks5_roundtrip、socks5_roundtrip_over、socks5_roundtrip_domain、Socks5Udp |
一个手写的 SOCKS5 客户端(无认证,IPv4 或域名目标),独立于内核自己的 codec。socks5_roundtrip_over 在已经打开的流(例如 Unix socket)上运行。Socks5Udp 在关联的整个生命周期内保持控制连接打开,并在返回每个回复时附上 SOCKS 头部所归属的地址。 |
udp_echo、tagged |
一个 UDP echo,回复前加上 echo 服务器自己的端口,这样测试可以把“哪个对端收到了包”和“回复被归属到哪个对端”区分开。 |
self_signed_pem、self_signed_pem_san、write_certs、write_certs_san、pinned_hash |
证书。Xray 测试按 SHA-256 固定(pinnedPeerCertSha256)只有 CN 的证书;Hysteria 测试需要 SubjectAltName,因为 rustls 拒绝只有 CN 的叶证书。 |
real_client_hello |
由 OpenSSL 为指定 SNI 生成的 ClientHello,用于嗅探测试。 |
fake_dns |
一个 UDP DNS 服务器,对某一个名字的 A 查询回答 127.0.0.1,其余一律回答 NXDOMAIN。 |
用 Go 构建的上游二进制
Section titled “用 Go 构建的上游二进制”互通测试运行参考实现,它们从 Etemenanki 仓库的 Xray-core 和 hysteria submodule 构建。每个二进制在每个测试二进制中只构建一次,在首次使用时通过 LazyLock 进行:
| Static | 命令 | 工作目录 | 输出 |
|---|---|---|---|
XRAY_BIN |
go build -o <out> ./main |
Xray-core |
CARGO_TARGET_TMPDIR/xray |
HYSTERIA_BIN |
go build -o <out> . |
hysteria/app(该仓库是 Go workspace;main 位于 app 模块的根目录) |
CARGO_TARGET_TMPDIR/hysteria |
flowchart TD
A["首次使用 XRAY_BIN 或 HYSTERIA_BIN"] --> K{"仅 katana:同级源码目录存在?"}
K -- 否 --> S["打印 SKIP 行,值为 None"]
K -- 是 --> G{"go version 成功?"}
G -- 否 --> S
G -- 是 --> B["go build -o CARGO_TARGET_TMPDIR/name"]
B -- 失败 --> S
B -- 成功 --> R["Some(path):测试运行"]
S --> P["每个测试提前返回并通过"]
测试本身,或它调用的共享辅助函数,以 let Some(xray) = XRAY_BIN.as_ref() else { return; }; 开头(e2e_xray.rs 中的 run_app_client 和 run_app_server),或在返回 Option 的辅助函数中使用 ?(e2e_xray_mux.rs 中的 start)。因此,一台没有 Go 或没有检出 submodule 的机器会让整套测试全绿,却没有执行任何互通测试。在 app 的支持代码中,submodule 目录完全不存在时,构建直接返回 None,不打印 SKIP 行。go build 还需要参考源码树依赖的 Go 模块,来自模块缓存或网络;构建失败同样算作跳过。在相信一次全绿的运行之前,先安装 Go,并在 Etemenanki 仓库中运行 git submodule update --init。
各 e2e 模块覆盖的内容
Section titled “各 e2e 模块覆盖的内容”| 模块 | 上游 | 证明什么 |
|---|---|---|
e2e_xray |
Xray | VLESS over WebSocket 和 gRPC(各自明文和 TLS),以及 VLESS over TCP with TLS,两种角色都覆盖(app 作客户端、app 作服务端) |
e2e_xray_vmess |
Xray | VMess over gRPC with TLS(app 作服务端),以及不带 TLS 的 WebSocket early data,两种角色都覆盖 |
e2e_xray_mux |
Xray | mux.cool 对接 VLESS、VMess 和 Trojan 服务端,并发子流,XUDP,回复归属 |
e2e_hysteria |
Hysteria 服务端 | 本内核的 Hysteria 2 客户端:一次认证后的多个代理流、私有 CA、被拒绝的密码、半关闭、Salamander、UDP 分片,以及从 app 的 SOCKS 入站进入 hysteria2 出站 |
e2e_hysteria_inbound |
Hysteria 客户端 | 本内核的 Hysteria 2 入站:TCP 和 UDP、双向分片的大数据报、混淆、用户名和密码凭据、错误凭据、重新绑定 UDP 端口的一次重载 |
e2e_sniff |
Xray 客户端 | 嗅探出的 HTTP Host 或 TLS SNI 让以 IP 寻址的流按域名规则路由,关闭嗅探后不再如此 |
e2e_udp_route、e2e_route_context、e2e_dns、e2e_balancer、e2e_unix |
无 | 按 UDP 包路由,inbound_tag、source_cidr 和 network 匹配条件,DNS 后端,负载均衡器故障转移,Unix socket 监听器;流量由 app 自己的 SOCKS 入站产生,所以这些测试不需要 Go |
e2e_wg |
进程内对端 | 通过真实二进制的 WireGuard 出站 |
e2e_tun |
内核 TUN 设备 | tun 入站应答一次被路由的连接,进程退出后该路由随之消失 |
sequenceDiagram participant T as 测试 participant X as xray 服务端 participant A as etemenanki-app participant E as tcp_echo T->>T: 首次使用时构建 XRAY_BIN T->>X: 在 test_dir 中用 xray.json 调用 spawn_xray T->>A: 用 app.toml 调用 spawn_app T->>X: wait_port(xray_port) T->>A: wait_port(socks_port) T->>A: SOCKS5 CONNECT 到 echo,然后发送 payload A->>X: VLESS over ws、gRPC 或 TLS X->>E: freedom E-->>T: 回显沿原路返回 T->>T: 15 秒内断言字节完全一致
需要特权的测试与在线测试
Section titled “需要特权的测试与在线测试”e2e_tun 只在 Linux 上编译(#![cfg(all(unix, target_os = "linux"))]),它唯一的测试 a_routed_connect_is_answered_while_the_app_runs 需要 CAP_NET_ADMIN。它的 can_create_a_device 会先自己打开一个 TUN 设备;遇到 PermissionDenied 时打印 SKIP: cannot create a tun device: … 并返回,遇到其他错误则 panic。模块注释给出了以特权运行它的命令:sudo -E cargo test -p etemenanki-app --test integration e2e_tun。
e2e_wg::wireguard_outbound_live_env_tcp 标有 #[ignore],会通过网络连接一个真实的 WireGuard 对端。它读取 ETEMENANKI_WG_PRIVATE_KEY、ETEMENANKI_WG_PEER_PUBLIC_KEY、ETEMENANKI_WG_ENDPOINT 和 ETEMENANKI_WG_ADDRESS(必需),以及 ETEMENANKI_WG_MTU、ETEMENANKI_WG_KEEPALIVE、ETEMENANKI_WG_RESERVED(恰好三个以逗号分隔的字节)、ETEMENANKI_WG_TEST_TARGET(一个 IPv4 socket 地址)和 ETEMENANKI_WG_TEST_HOST(可选)。缺少必需变量时会 panic,信息为 missing <name>; run this ignored test with real WG settings。当一个穿过隧道的 HTTP 请求在 20 秒内得到以 HTTP/ 开头的响应时,测试通过。
katana 测试
Section titled “katana 测试”katana 是一个二进制 crate。它的全部单元测试,包括进程内端到端测试,都编译进 katana 二进制的测试 harness;集成测试目标以 CARGO_BIN_EXE_katana 启动构建好的二进制。katana 的 dev-dependencies 启用了 tokio 的 test-util,以及用于生成证书的 vendored openssl。
单元测试与暂停时钟
Section titled “单元测试与暂停时钟”| 文件 | 挂载位置 | 覆盖 |
|---|---|---|
tests/unit/api/newv2board.rs、tests/unit/api/sspanel.rs |
src/api/… |
面板响应解析与推送 body 的结构;对 newV2board,重建的客户端在自己读到节点配置之前,沿用被替换客户端的 block 路由(inherit_routes) |
tests/unit/connector.rs |
src/connector.rs |
按用户和 uid 的准入、审计拦截、流和 UDP 的计费、租约传递到连接 |
tests/unit/inbound.rs、tests/unit/outbound.rs |
src/inbound.rs、src/outbound/mod.rs |
根据面板数据和配置构建节点与出站,以及各种拒绝情形 |
tests/unit/meter.rs |
src/meter.rs |
Metered 的分方向计费与速率限制、退役 |
tests/unit/rule.rs |
src/rule.rs |
审计规则的更新与记录 |
tests/unit/runtime.rs |
src/runtime.rs |
对运行中节点检验重载身份判定与 apply_reload 的决策(见对运行中节点的重载测试) |
tests/unit/serve.rs |
src/serve.rs |
每连接驱动器的 deadline 与退役 |
tests/unit/traffic.rs |
src/traffic.rs |
计数器、残余、速率变更、令牌桶 |
速率限制和 deadline 测试运行在 #[tokio::test(start_paused = true)] 上;令牌桶测试直接调用 TokenBucket,meter 和 serve 测试运行在 tokio::io::duplex 上。时钟暂停时,只要运行时没有其他事可做,tokio 就把时间推进到下一个定时器,因此测试可以断言时长而不必真的等待:
| 测试 | 文件 | 固定的行为 |
|---|---|---|
a_chunk_larger_than_the_burst_is_still_limited |
tests/unit/traffic.rs |
30 000 字节在 10 000 B/s 的桶上恰好耗时 2 秒:超出突发量的部分成为欠额 |
debt_holds_back_the_next_charge_too |
tests/unit/traffic.rs |
charge(25_000) 恰好在 1500 毫秒内还清,下一个调用者要等它还完 |
an_idle_bucket_banks_one_second_and_no_more |
tests/unit/traffic.rs |
空闲 60 秒后,突发量仍然只有一秒的速率 |
the_limit_holds_however_the_writes_are_sized |
tests/unit/meter.rs |
通过 Metered 的一次 30 000 字节写入,会让下一次写入延迟至少 2 秒 |
the_limit_is_shared_by_both_directions |
tests/unit/meter.rs |
在 10 000 B/s 的桶上上传 20 000 字节,会让下一次下载延迟至少 1 秒 |
a_silent_client_is_dropped_at_the_handshake_deadline |
tests/unit/serve.rs |
恰好经过 HANDSHAKE_TIMEOUT(10 秒)后,drive 以 Ended::Handshake 结束 |
a_connection_that_stops_moving_is_dropped |
tests/unit/serve.rs |
停止读取的客户端以 Ended::Relay(“nothing moved”)被断开,且不早于 PROGRESS_WATCHDOG,即 RELAY_IDLE_TIMEOUT 加 60 秒(360 秒) |
内核的 deadline 也用同样的技术固定:concepts/tests/runtime.rs 中的 deadline_event_lets_the_core_time_out 和 deadline_is_armed_against_the_tokio_clock,以及 protocols/tests/unit/transports/ 中的 gRPC 存活与 accept 测试。其中第二个测试之所以存在,是因为基于 std::time::Instant 设置的 deadline 会无视暂停时钟;该测试先把 tokio 时钟推进一小时,再检查一个 5 秒的空闲 deadline 既不会提前触发,也不会永不触发。
进程内端到端测试
Section titled “进程内端到端测试”tests/unit/e2e.rs 按运行时的方式构建节点,只是不启动进程:一个指向假面板的 PanelClient::NewV2board、一个 NodeManager,以及一个由测试持有的静态更新通道。NodeManager::run 先让节点启动起来(面板配置、用户、绑定),不断重试直到启动成功或其 token 被取消,然后才开始提供服务。
impl NodeManager { pub fn new( api: PanelClient, cfg: NodeConfig, pool: Arc<HashMap<CompactString, Arc<Outbound>>>, ) -> io::Result<Arc<Self>>;
pub async fn run( self: Arc<Self>, shutdown: CancellationToken, mut static_rx: mpsc::Receiver<StaticUpdate>, );}假面板是测试中手写的一个 HTTP/1.1 服务器,监听 127.0.0.1:0。read_request 把每个请求解析为 Request { path, if_none_match, body },面板按路径匹配:路径包含 /UniProxy/config 的请求得到节点配置 body,/UniProxy/user 得到当前用户 body,/UniProxy/push 的 body 被捕获并返回 {}。其他路径一律返回 {}。各构造函数层层叠加:
| 构造函数 | 增加的内容 |
|---|---|
fake_panel(node_port, uuid) |
一个用户(uid 1001),以及 node_port 上的一个明文 TCP V2ray 节点 |
fake_panel_dynamic(node_port, user_body) |
从一个 Arc<Mutex<String>> 提供用户 body,因此测试可以在不同阶段之间改变用户集合 |
fake_panel_with_config(config_body, user_body) |
还接受节点配置 body |
fake_panel_quirky(config_body, user_body, quirks) |
表现得像一个状态不佳的真实面板,具体行为由 Quirks 设定,另外还返回一个统计节点配置请求次数的 Arc<AtomicUsize> |
fake_sspanel(node_port, uuid) |
一个单独的 SSpanel mod_mu 假面板:一个用旧式 server 字符串描述的 V2ray 节点(其 VLESS 标志来自客户端自己的配置),以及一个用户(uid 1001) |
Quirks 有两个字段。config_failures 对前 N 次节点配置请求回答 500 Internal Server Error,模拟节点启动时面板尚未就绪。etags 给每个配置和用户应答加上 ETag,并像 Xboard 那样,对 If-None-Match 与之匹配的请求回答 304 Not Modified。wait_config_requests(requests, n) 一直等到面板被请求节点配置 n 次。
默认配置 body 是 {"server_port":…,"network":"tcp","tls":0}:本套测试中的 V2ray 节点只跑明文 TCP,传输层留给集成测试。Hysteria 2 测试在本地设置 [node.hysteria].port,这会让节点跳过面板的节点配置(用户仍来自面板);例外是 a_panel_described_hysteria_node_serves_obfuscated_traffic,它把端口保留为 0,并通过 fake_panel_with_config 提供自己的 body。
test_node_cfg 设置 update_periodic = 1,因此节点每秒轮询一次面板并推送流量。客户端都是内核自己的:TCP 上的 VMess 或 VLESS ProxyClientRuntime(vmess_client、vless_client),Hysteria 2 节点则使用带 VerifyMode::Insecure 的 Hy2Conn::connect。
tests/unit/runtime.rs 共用的夹具是 pub(crate) 的:
| 辅助函数 | 行为 |
|---|---|
roundtrip(reader, writer, payload, limit) |
在一条已打开的连接上写入 payload,并期望在 limit 内收到完全一致的回显 |
vmess_roundtrip、vless_roundtrip |
同上,但使用一条新建的 VMess 或 VLESS 连接 |
wait_relays(node, uuid, echo) |
每 100 毫秒重试一次限时 2 秒的 vmess_roundtrip,直到节点开始中继,10 秒后 panic:仍在启动中的节点在启动完成前会拒绝或断开连接 |
tcp_echo、fake_panel、fake_sspanel、test_node_cfg、build_test_pool、wait_bound |
echo 目标、各假面板、指向某个面板地址的节点配置、direct/block 出站池,以及等待 TCP 绑定 |
SPARE_LOOPBACK 是 127.0.0.2。套件中其他所有监听器都在 127.0.0.1 上,所以测试在备用地址上释放的端口只可能被本该占用它的节点拿到,无论主机从哪个范围分配端口 0。
| 测试 | 固定的行为 |
|---|---|
vmess_traffic_is_metered_and_reported |
一次 50 000 字节的回显完整中继,面板收到的推送 body 在每个方向上为 uid 1001 记录至少这么多字节 |
unchanged_user_survives_user_refresh |
新增用户不影响未变用户的活动连接;删除该用户后连接被断开;两个阶段的流量都被上报 |
proxy_outbound_relays_and_meters |
默认出站是指向测试内 VLESS 服务端的 VLESS 客户端的节点,能中继并计量 |
route_change_drops_connections |
带有不同 [node.route] 的 StaticUpdate::Config 会断开已打开的连接 |
a_hysteria_node_relays_and_meters |
Hysteria 2 节点通过 connector 中继并计费,这是 Hysteria 流与流式节点唯一共用的路径 |
a_hysteria_node_refuses_an_unknown_credential |
面板从未签发的凭据无法代理 |
a_retired_user_stops_while_the_rest_keep_their_connections |
让用户 B 退役会切断 B,而 A 已有的 QUIC 连接继续工作 |
a_panel_described_hysteria_node_serves_obfuscated_traffic |
从面板节点配置中获取的端口和 Salamander 设置能到达监听器 |
repeated_user_refreshes_never_disturb_a_live_connection |
六个 1.1 秒的周期,每个周期都翻转用户集合,每个周期结束后同一条 QUIC 连接仍然存活并在中继 |
a_node_comes_up_once_the_panel_answers |
在 config_failures: 2 下,节点持续请求,至少发出三次配置请求之后才绑定,然后能中继 |
a_node_whose_port_is_taken_comes_up_once_it_is_free |
一个监听器在 SPARE_LOOPBACK 上占住节点的端口,面板开启 etags。节点请求配置两次之后,测试释放该端口,节点随即启动:每次尝试都会丢弃上一次收到的 ETag,因此面板返回完整响应,而不是 304 Not Modified |
a_node_that_never_bootstraps_still_stops |
在每次配置请求都失败的情况下,被取消的节点任务在 2 秒内结束,而不是继续重试 |
这些测试使用真实时钟并轮询结果(例如上报总量要检查 150 次、每次间隔 100 毫秒),因为被测对象正是节点自己的定时器。
对运行中节点的重载测试
Section titled “对运行中节点的重载测试”tests/unit/runtime.rs 从 src/runtime.rs 挂载,因此可以直接调用私有的 identity、display_id、spawn_node 和 apply_reload。它的 Runtime 结构体围绕一个节点,持有 run 为其节点持有的那些东西(cfg、pool、root 和节点句柄):
| 方法 | 行为 |
|---|---|
start(node_cfg) |
用 build_test_pool 的出站池,通过 spawn_node 启动一个节点,并记录一个包含该节点的 Config |
reload(new) |
用新的 Config 和一个什么也不做的日志重载调用 apply_reload |
stop() |
取消根 token,并等待每个节点任务结束 |
测试通过比较节点句柄的 static_tx 与重载前克隆的一份(same_channel),区分节点是被重新启动还是被原地重新配置。前三个测试只比较身份,不需要节点。
| 测试 | 固定的行为 |
|---|---|
an_sspanel_node_is_its_panel_node_id |
对 SSpanel,只有 panel_type、api.host、api.node_id 和 api.key 会让一个条目变成另一个节点。enable_vless、node_type、vless_flow、speed_limit、rule_list_path、disable_custom_config、device_limit、timeout 以及 panel_type 的大小写都不改变节点身份 |
a_newv2board_node_is_also_the_type_it_asks_for |
对 NewV2board,节点向面板请求的节点类型也属于身份:node_type,以及 V2ray 节点上的 enable_vless,会让它变成另一个节点。node_type 的大小写、speed_limit、rule_list_path 和 timeout 不会,Trojan 节点上的 enable_vless 也不会 |
a_node_is_logged_by_its_panel_node_not_its_key |
display_id 对 NewV2board 渲染为 <panel_type>@<host>#<node_id>/<type>(<type> 是节点向面板请求时使用的节点类型),对 SSpanel 渲染为 <panel_type>@<host>#<node_id>,面板类型和节点类型都转为小写,且从不包含 key |
a_newv2board_type_edit_respawns_the_node |
在 NewV2board 节点上关闭 enable_vless 会重新启动该节点;新节点提供 VMess,拒绝 VLESS |
a_reload_with_a_node_that_does_not_build_changes_nothing |
同一条目中既有 node_type 拼写错误又有路由修改的重载,会保留正在运行的节点,两处修改都不记录,活动连接继续中继 |
an_sspanel_api_edit_takes_effect_in_place |
对接 fake_sspanel,关闭 enable_vless 会原地重新配置节点;节点重建其面板客户端,提供 VMess,断开已打开的 VLESS 连接并拒绝新的 VLESS 连接 |
a_client_edit_takes_effect_without_dropping_connections |
把 rule_list_path 指向一个禁止访问 echo 目标的文件,会原地重新配置节点:10 秒内发往该目标的新流被拒绝,而已打开的连接继续中继 |
对接 FakePanel 的集成测试
Section titled “对接 FakePanel 的集成测试”tests/support/mod.rs 重复了 app 支持代码的核心部分(不带 terminate 的 Proc、spawn_xray、spawn_hysteria_client、free_port、free_udp_port、wait_port、test_dir、socks5_roundtrip、证书和固定),并增加了一个可复用的面板:
pub struct FakePanel { pub addr: SocketAddr, /* private: pushes */}
impl FakePanel { pub async fn spawn(config_body: String, user_body: String) -> Self; pub fn reported_totals(&self, uid: i64) -> (i64, i64); pub async fn wait_for_traffic(&self, uid: i64, timeout: Duration) -> (i64, i64);}
pub fn spawn_katana(dir: &Path, config: &str) -> Proc;pub fn users_body(users: &[(i64, &str)]) -> String;pub fn v2ray_config_body(port: u16, network: &str, tls: bool, vless: bool) -> String;pub fn hysteria_config_body(port: u16) -> String;pub fn trojan_config_body(port: u16) -> String;pub async fn wait_udp_bound(port: u16);FakePanel 提供与进程内假面板相同的三个 UniProxy 路径,但 body 固定。wait_for_traffic 在该 uid 的任一方向总量大于 0 时立即返回,超时则返回当时已有的值;由调用方对结果做断言。
v2ray_config_body 对 VLESS 把传输层设置写在 network_settings 下,对 VMess 写在 networkSettings 下,因为 katana 的解析器分别从这两处读取。Xray 和 Hysteria 的构建指向同级的 Etemenanki 检出目录(../Etemenanki/Xray-core、../Etemenanki/hysteria),路径由 CARGO_MANIFEST_DIR 推导;单独克隆的 katana 会打印 SKIP: … not found 并通过。
sequenceDiagram participant T as 测试 participant P as FakePanel participant K as katana 二进制 participant X as xray 客户端 participant E as tcp_echo T->>P: FakePanel::spawn(配置 body, 用户 body) T->>K: 用 katana.toml 调用 spawn_katana K->>P: 请求节点配置和用户 K->>K: 绑定节点端口 T->>K: wait_port(node_port) T->>X: spawn_xray,wait_port(socks_port) T->>X: socks5_roundtrip(echo, payload) X->>K: 所选传输层上的 VMess、VLESS 或 Trojan K->>E: direct 出站 E-->>T: 回显的字节 K->>P: 每个 update_periodic(1 秒)推送一次流量 T->>P: wait_for_traffic(UID, 15 s) T->>T: 断言上行和下行都大于 0
| 模块 | 测试 |
|---|---|
xray_interop |
矩阵 vmess_tcp_plain、vmess_ws_plain、vmess_grpc_plain、vmess_tcp_tls、vless_ws_tls、vless_grpc_tls、trojan_tcp_tls,以及启用 Xray mux 的 vmess_tcp_plain_mux、vless_ws_tls_mux、trojan_tcp_tls_mux。每项都断言回显完全一致,并且面板收到了 uid 1001 的流量。TLS 各项通过固定来验证 katana 的自签名叶证书,而不是关闭验证。 |
sniff |
a_sniffed_host_reaches_a_domain_rule 和 disable_sniffing_stops_the_domain_rule_matching:节点的路由除一条域名规则外丢弃一切。 |
hysteria_interop |
a_real_client_proxies_through_a_katana_hysteria_node:官方客户端经其 SOCKS5 前端驱动,用裸 UUID 认证并中继,然后通过同一个客户端再中继一次,这一次走的是同一条 QUIC 连接上的新流。它不断言流量上报。 |
katana 按其 Cargo.lock 中的版本从私有 Cargo registry 获取内核 crate。因此它的测试检验的是已发布的内核,而不是 Etemenanki 的工作树:内核改动只有在发布并且 katana 的 lockfile 升级之后,才会进入 katana 的测试。
测试套件的不变量
Section titled “测试套件的不变量”| 不变量 | 机制 | 位置 |
|---|---|---|
| 除一个被 ignore 的测试外,没有测试需要互联网访问(构建上游二进制可能需要下载 Go 模块) | 每个对端和目标都是测试内的服务器、监听 loopback 的子进程,或进程内 WireGuard 对端 | 所有套件;例外是 wireguard_outbound_live_env_tcp |
| 互通测试绝不会因为缺少 Go、submodule 或特权而失败 | XRAY_BIN/HYSTERIA_BIN 为 None,每个测试提前返回;can_create_a_device 在 PermissionDenied 时返回 false |
app/tests/support/mod.rs、katana/tests/support/mod.rs、e2e_tun.rs |
| 失败的测试不会留下进程 | Proc 在 Drop 中杀掉并回收其子进程 |
两个支持模块 |
| 以分钟计的 deadline 无需真的等待即可测试 | start_paused = true;内核在 tokio 时钟上设置 deadline |
deadline_is_armed_against_the_tokio_clock、a_connection_that_stops_moving_is_dropped |
| 有缺陷的核心或 codec 会让运行时测试失败,而不是卡住 | 违反约定会表现为 RuntimeError 或 io::Error;测试用 tokio::time::timeout 限制等待时间 |
a_held_range_past_the_buffer_is_rejected、handshake_step_without_progress_is_rejected、zero_length_frame_is_rejected |
| 协议的两半互相对测,并在存在独立实现时与之对测 | 先是流水线测试,然后是 Xray 和 Hysteria 互通测试 | protocols/tests/pipeline/、app/tests/integration/、katana/tests/integration/ |
# 门槛运行的全部测试(protocols 会获得 app 启用的 hysteria 和 tun feature)cargo test --workspace
# 只运行某个 crate 的单元测试cargo test -p etemenanki-protocols --libcargo test -p etemenanki-app --bin etemenanki-app
# 某个协议的单元测试(按模块路径过滤)cargo test -p etemenanki-protocols --lib trojan::
# 运行时与客户端运行时测试cargo test -p etemenanki-concepts --test runtimecargo test -p etemenanki-concepts --test client
# 某个协议的流水线测试cargo test -p etemenanki-protocols --test pipeline pipeline::trojancargo test -p etemenanki-protocols --features hysteria --test pipeline pipeline::hysteriacargo test -p etemenanki-protocols --features tun --test pipeline pipeline::tun
# 拨号器,包括没有任何 workspace 成员启用的 QUICcargo test -p etemenanki-environment --features quic --test integration
# App 端到端测试,单个模块,显示 SKIP 行cargo test -p etemenanki-app --test integration e2e_xray -- --nocapture
# 需要特权的测试与在线测试sudo -E cargo test -p etemenanki-app --test integration e2e_tuncargo test -p etemenanki-app --test integration wireguard_outbound_live_env_tcp -- --ignored# 门槛运行的全部测试cargo test --locked
# 单元测试和进程内 e2e(都在二进制的 harness 中)cargo test --locked --bin katanacargo test --locked --bin katana e2e::cargo test --locked --bin katana traffic::tests::cargo test --locked --bin katana runtime::tests::
# 对接 FakePanel 的集成测试,单个模块cargo test --locked --test integration xray_interop -- --nocapturecargo test --locked --test integration hysteria_interop -- --nocapture过滤条件匹配完整测试路径的子串。路径与模块布局对应:
| 类型 | 路径示例 |
|---|---|
| 挂载的单元测试 | trojan::core::tests::header_split_across_events_opens_once_complete |
| 流水线测试 | pipeline::trojan::new_server_vs_new_client_tcp |
| App 集成测试 | e2e_xray::app_client_ws_xray_server_plain |
| katana 进程内 e2e | e2e::vmess_traffic_is_metered_and_reported |
| katana 重载测试 | runtime::tests::a_newv2board_type_edit_respawns_the_node |
| katana 集成测试 | xray_interop::vmess_ws_plain |
只有在改动涉及的每个仓库中都通过完整门槛之后,改动才能合入。CI workflow 只构建 release 二进制,不运行其中任何一项,所以门槛要在本地运行。
cargo fmt --all -- --checkcargo test --workspacecargo clippy --workspace --all-targets --all-features -- -D warnings# 发布前还要运行:cargo check --workspace --lockedcargo fmt --all -- --checkcargo test --lockedcargo clippy --all-targets --all-features -- -D warnings# 发布前还要运行:cargo metadata --locked --no-deps --format-version 1cargo check --lockedclippy 门槛中的 --all-targets 也会 lint 集成测试 crate,--all-features 会打开 quic,因此即使 cargo test --workspace 不运行 QUIC 拨号器测试,它们至少也会被编译和 lint。会 panic 的写法由 clippy lint 负责:cargo test 编译库代码中的 unwrap() 时不会有任何抱怨,只有 clippy 门槛会拒绝它。协议、传输层、WireGuard、SOCKS 和 TLS 的改动还应在装有 Go 的环境中带 --nocapture 运行互通测试,并确认没有出现 SKIP 行。
Harness 中的限制与超时
Section titled “Harness 中的限制与超时”| 常量或取值 | 位置 | 值 |
|---|---|---|
HARNESS_STAGING |
protocols/src/core/harness.rs |
每个 CoreHarness 64 KiB 暂存区 |
MTU、SCRATCH、LISTENERS |
protocols/tests/support/wireguard.rs |
1420、64 KiB、4 |
ECHO_PORT、SINK_PORT |
同上 | 5555(TCP 和 UDP echo)、5556(TCP sink) |
| 停滞流上限 | protocols/tests/pipeline/wireguard.rs |
向停滞的 sink 写入、某次写入等待超过 1 秒之前,最多接受 512 KiB |
| WireGuard 对端定时器 | 同上 | 每 200 毫秒一次 update_timers |
wait_port |
app 和 katana 的支持代码 | 20 秒,每 50 毫秒轮询一次 |
wait_udp_port |
app/tests/support/mod.rs |
20 秒,每 50 毫秒轮询一次 |
wait_udp_bound |
katana/tests/support/mod.rs |
尝试 400 次、间隔 50 毫秒(20 秒),然后 panic |
wait_bound、wait_udp_bound |
katana/tests/unit/e2e.rs |
尝试 200 次、间隔 50 毫秒(10 秒),然后 panic |
wait_config_requests |
katana/tests/unit/e2e.rs |
尝试 200 次、间隔 50 毫秒(10 秒),然后 panic |
wait_relays |
katana/tests/unit/e2e.rs |
10 秒期限,每次往返 2 秒,两次尝试间隔 100 毫秒,然后 panic |
roundtrip、vmess_roundtrip、vless_roundtrip |
katana/tests/unit/e2e.rs、katana/tests/unit/runtime.rs |
由调用方指定:必须收到回显时为 10 秒,必须失败时为 2 秒 |
Proc::terminate |
app/tests/support/mod.rs |
SIGTERM 后等待 10 秒,然后由 Drop 发送 SIGKILL |
| 往返超时 | e2e_xray、e2e_xray_vmess、e2e_xray_mux、e2e_wg |
每次 SOCKS 往返 15 秒;部分 e2e_xray_mux 用例允许 20 秒或 30 秒 |
FakePanel::wait_for_traffic |
katana 集成测试 | 每 100 毫秒轮询一次,直到调用方给定的超时(xray_interop 中为 15 秒) |
测试配置中的 update_periodic |
katana e2e 和集成测试 | 1 秒 |
| 违反约定时的读取 | concepts/tests/client.rs |
1 秒超时;触发超时说明运行时空转或卡住了 |
-
线上格式。 在
protocols/tests/unit/<proto>/protocol.rs中(从src/<proto>/protocol.rs挂载):编码和解码的往返测试,以及每个固定字段(version、保留字节、长度、command 或 type)各一个负向测试,证明错误的值会报错,而不是被静默接受为合法头部。截断的输入必须请求更多数据,超出协议上限的长度必须失败。只测试解析器实际做了什么;不要假设对端是正确的实现。 -
客户端 codec。 在
tests/unit/<proto>/codec.rs中:不涉及 I/O 的seal和open,跨调用拆分的帧返回Opened::NeedMore,以及finish。concepts/tests/client.rs中的codec_is_driven_without_any_io测试可以作为模板。 -
服务端核心。 在
tests/unit/<proto>/core.rs中,通过CoreHarness:跨事件拆分的头部只有在完整后才打开;错误凭据以PermissionDenied失败;开启嗅探时 IP 目标会保留前缀,并在得到主机名或SNIFF_TIMEOUT到期时打开;域名目标从不嗅探;中继在每个方向各自半关闭,第二个方向关闭时结束;ConnectFailed按协议要求的方式结束连接;建立之前收到Event::Deadline会以握手超时失败;对 UDP,不完整的包帧会等待剩余部分。protocols/tests/unit/trojan/core.rs对每一项都有一个测试。 -
流水线。 新建
protocols/tests/pipeline/<proto>.rs,在tests/pipeline.rs中声明(如果协议受 feature 控制,放在#[cfg(feature = …)]之后)。至少包括:new_server_vs_new_client_tcp,payload 大于核心的BUF_SIZE,随后shutdown并得到空的read_to_end;以及new_server_vs_new_client_udp,使用 1500 字节的数据报并断言回复的来源。UDP 中继还需要一个负向测试,证明来自关联之外来源的数据报不会被中继,SOCKS 关联测试就用recording_echo做了这件事。使用serve_runtime::<{ YourCore::<()>::BUF_SIZE }, _, _>,而不是手选的大小。 -
App。 在构建器旁边的单元测试(
app/tests/unit/inbound.rs、outbound.rs、transport.rs)中验证:最小配置能构建成功,每个必需设置都会被强制检查,未知设置会被拒绝,不支持的传输层或 security 组合会 fail closed,并给出指明修复方法的信息。断言错误文本中有辨识度的部分。 -
互通。 如果 Xray 或 Hysteria 实现了该协议,为 app 支持的每个方向各写一个
e2e_*测试,通过socks5_roundtrip驱动并断言回显完全一致。如果适用 mux.cool 或 XUDP,在e2e_xray_mux中加一项。 -
katana(如果它提供该协议)。 在
katana/tests/unit/inbound.rs中添加节点构建测试,在xray_interop矩阵中加一项并同时断言流量上报;对于新的凭据形态,在tests/unit/connector.rs中添加准入测试。 -
门槛。 在装有 Go 的环境中运行完整门槛,对互通模块带上
--nocapture,并检查没有任何输出SKIP。
修改运行时或事件约定时
Section titled “修改运行时或事件约定时”- 用
concepts/tests/runtime.rs中的玩具核心或concepts/tests/client.rs中的玩具 codec 固定行为,不要用真实协议。现有玩具能表达该情形时,扩展TinyMux或Scripted;不能时新增一个玩具,并在文档注释中写明其线上格式。 - 背压改动需要一个某一侧停滞的测试:像
stalled_outbound_holds_uplink_but_not_other_downlink那样使用小的远端管道,或像backpressure_from_the_wire_reaches_the_writer那样使用小的上游。既要断言停滞的方向在等待,也要断言无关的方向继续流动。路径上任何新增的队列或缓冲区都需要上限,以及一个证明消费者阻塞时生产者会停下的测试,就像a_stalled_tcp_flow_blocks_its_writer和a_held_item_keeps_the_channel_unread对 WireGuard 驱动器所做的那样。 - Deadline 改动需要一个运行在
duplex上的start_paused测试。暂停时钟的测试中不要使用真实 socket:只要运行时没有工作,tokio 就会自动推进暂停的时钟,包括某个任务在等待真实 socket 的时候,所以定时器可能在 socket 交付数据之前就触发。 - 新的
RuntimeError变体或新的约定检查,需要一个触发它的测试,并用tokio::time::timeout限制等待时间,这样回归时只会卡一秒,而不是永远卡住。 - 数据报路径需要覆盖截断和拒收的情形:
datagram_outbound_is_never_truncated_by_staging_backpressure、a_refused_datagram_send_keeps_the_key_alive、a_refused_transport_packet_is_reported_not_fatal。 - 然后带
--features hysteria,tun运行整个pipeline目标,并运行 app 的 e2e 模块:除 SOCKS 外,每个入站都运行在服务端运行时中。
修改 katana 时
Section titled “修改 katana 时”- 速率限制与计量:一个
start_paused测试,断言精确的耗时,并使用大于桶容量的数据块。无论字节如何分块,桶都必须守住速率。 - 流量计费:在
tests/unit/traffic.rs中测试上报失败(保留计数器和残余)、重试上报(不重复计数)、用户被删除后重新添加,以及落在快照与提交之间的增量(commit_reported_preserves_concurrent)。 - 节点生命周期与用户同步:在
tests/unit/e2e.rs中写一个进程内测试,使用fake_panel_dynamic在连接打开期间改变用户集合。 - 启动、重试与条件请求:对接
fake_panel_quirky的进程内测试,用Quirks { config_failures }模拟尚未就绪的面板,用Quirks { etags }模拟回答304 Not Modified的面板,并用wait_config_requests判断节点进行到了哪一步。 - 重载与节点身份:在
tests/unit/runtime.rs中通过Runtimeharness 编写测试。身份变化需要一个identity断言和一个重新启动的测试;其他修改需要一个测试证明它在运行中的节点上生效,并且在构建监听器所用的内容都没有变化时保留已打开的连接。 - 凭据日志:当测试输入格式错误的凭据时,断言错误信息不会回显它,就像
tests/unit/outbound.rs中的a_malformed_uuid_is_refused_without_echoing_it那样。 - 新的面板字段或节点类型:在
tests/unit/api/中添加解析测试;如果集成测试需要,在tests/support/mod.rs中添加配置 body 辅助函数。