跳转到内容

测试

源码文件:65 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/Cargo.toml
  • Etemenanki/protocols/Cargo.toml
  • Etemenanki/app/Cargo.toml
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/concepts/src/client.rs
  • Etemenanki/concepts/src/link.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/concepts/tests/client.rs
  • Etemenanki/environment/src/lib.rs
  • Etemenanki/environment/tests/integration.rs
  • Etemenanki/environment/tests/integration/udp.rs
  • Etemenanki/environment/tests/unit/dial/socket.rs
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/core/harness.rs
  • Etemenanki/protocols/tests/pipeline.rs
  • Etemenanki/protocols/tests/pipeline/core.rs
  • Etemenanki/protocols/tests/pipeline/trojan.rs
  • Etemenanki/protocols/tests/pipeline/vless.rs
  • Etemenanki/protocols/tests/pipeline/vmess.rs
  • Etemenanki/protocols/tests/pipeline/shadowsocks.rs
  • Etemenanki/protocols/tests/pipeline/transports.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/pipeline/hysteria.rs
  • Etemenanki/protocols/tests/pipeline/wireguard.rs
  • Etemenanki/protocols/tests/pipeline/tun.rs
  • Etemenanki/protocols/tests/support/mod.rs
  • Etemenanki/protocols/tests/support/pipeline.rs
  • Etemenanki/protocols/tests/support/wireguard.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/protocols/tests/unit/trojan/core.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/server.rs
  • Etemenanki/protocols/tests/unit/socks/protocol.rs
  • Etemenanki/protocols/src/wireguard/device.rs
  • Etemenanki/protocols/tests/unit/wireguard/device.rs
  • Etemenanki/app/src/main.rs
  • Etemenanki/app/tests/integration.rs
  • Etemenanki/app/tests/support/mod.rs
  • Etemenanki/app/tests/integration/e2e_xray.rs
  • Etemenanki/app/tests/integration/e2e_xray_vmess.rs
  • Etemenanki/app/tests/integration/e2e_xray_mux.rs
  • Etemenanki/app/tests/integration/e2e_unix.rs
  • Etemenanki/app/tests/integration/e2e_hysteria.rs
  • Etemenanki/app/tests/integration/e2e_hysteria_inbound.rs
  • Etemenanki/app/tests/integration/e2e_wg.rs
  • Etemenanki/app/tests/integration/e2e_tun.rs
  • katana/Cargo.toml
  • katana/src/main.rs
  • katana/src/runtime.rs
  • katana/src/serve.rs
  • katana/src/traffic.rs
  • katana/src/manager/mod.rs
  • katana/src/manager/node.rs
  • katana/tests/integration.rs
  • katana/tests/support/mod.rs
  • katana/tests/integration/xray_interop.rs
  • katana/tests/integration/sniff.rs
  • katana/tests/integration/hysteria_interop.rs
  • katana/tests/unit/e2e.rs
  • katana/tests/unit/runtime.rs
  • katana/tests/unit/serve.rs
  • katana/tests/unit/traffic.rs
  • katana/tests/unit/meter.rs
  • katana/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] 属性挂载:

protocols/src/trojan/core.rs
#[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。

每个 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。

核心 传输层 线上格式 用途
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 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)。

两个运行时在构造时都会检查缓冲区与核心或 codec 是否匹配:

concepts/src/runtime.rs
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 手动驱动一个服务端核心:投递事件,收集核心推送的 effect 和它暂存的字节,不做任何 I/O。它放在库里而不是 cfg(test) 下,所以 crate 自己的单元测试和 downstream crate 都能使用。

protocols/src/core/harness.rs
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 中的辅助函数”
protocols/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) -> SocketAddr
where
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。

  • 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),并在 NewServer guard 被 drop 时取消 token 来停止它。它只在启用 hysteria feature 时编译。
  • TUN(pipeline/tun.rs,feature tun)用一个 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 流。

在 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 固定了两端共用的比较方式。

流式核心的 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 服务器的地址,而不只是字节一致。

protocols/tests/support/wireguard.rs 在测试进程内运行一个完整的 WireGuard 对端:一个 boringtun Tunn 桥接到一个回显 TCP 和 UDP 的 smoltcp 接口,外加一个由测试开关其读取的 TCP sink。不需要内核接口、特权或网络访问。

protocols/tests/support/wireguard.rs
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/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 中的支持代码”
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。

互通测试运行参考实现,它们从 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_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 秒内断言字节完全一致

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 是一个二进制 crate。它的全部单元测试,包括进程内端到端测试,都编译进 katana 二进制的测试 harness;集成测试目标以 CARGO_BIN_EXE_katana 启动构建好的二进制。katana 的 dev-dependencies 启用了 tokio 的 test-util,以及用于生成证书的 vendored openssl。

文件 挂载位置 覆盖
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 既不会提前触发,也不会永不触发。

tests/unit/e2e.rs 按运行时的方式构建节点,只是不启动进程:一个指向假面板的 PanelClient::NewV2board、一个 NodeManager,以及一个由测试持有的静态更新通道。NodeManager::run 先让节点启动起来(面板配置、用户、绑定),不断重试直到启动成功或其 token 被取消,然后才开始提供服务。

src/manager/node.rs
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 毫秒),因为被测对象正是节点自己的定时器。

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 秒内发往该目标的新流被拒绝,而已打开的连接继续中继

tests/support/mod.rs 重复了 app 支持代码的核心部分(不带 terminate 的 Proc、spawn_xray、spawn_hysteria_client、free_port、free_udp_port、wait_port、test_dir、socks5_roundtrip、证书和固定),并增加了一个可复用的面板:

tests/support/mod.rs
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 的测试。

不变量 机制 位置
除一个被 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 --lib
cargo test -p etemenanki-app --bin etemenanki-app
# 某个协议的单元测试(按模块路径过滤)
cargo test -p etemenanki-protocols --lib trojan::
# 运行时与客户端运行时测试
cargo test -p etemenanki-concepts --test runtime
cargo test -p etemenanki-concepts --test client
# 某个协议的流水线测试
cargo test -p etemenanki-protocols --test pipeline pipeline::trojan
cargo test -p etemenanki-protocols --features hysteria --test pipeline pipeline::hysteria
cargo test -p etemenanki-protocols --features tun --test pipeline pipeline::tun
# 拨号器,包括没有任何 workspace 成员启用的 QUIC
cargo 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_tun
cargo test -p etemenanki-app --test integration wireguard_outbound_live_env_tcp -- --ignored

过滤条件匹配完整测试路径的子串。路径与模块布局对应:

类型 路径示例
挂载的单元测试 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 -- --check
cargo test --workspace
cargo clippy --workspace --all-targets --all-features -- -D warnings
# 发布前还要运行:
cargo check --workspace --locked

clippy 门槛中的 --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_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 秒超时;触发超时说明运行时空转或卡住了
  1. 线上格式。 在 protocols/tests/unit/<proto>/protocol.rs 中(从 src/<proto>/protocol.rs 挂载):编码和解码的往返测试,以及每个固定字段(version、保留字节、长度、command 或 type)各一个负向测试,证明错误的值会报错,而不是被静默接受为合法头部。截断的输入必须请求更多数据,超出协议上限的长度必须失败。只测试解析器实际做了什么;不要假设对端是正确的实现。

  2. 客户端 codec。 在 tests/unit/<proto>/codec.rs 中:不涉及 I/O 的 seal 和 open,跨调用拆分的帧返回 Opened::NeedMore,以及 finish。concepts/tests/client.rs 中的 codec_is_driven_without_any_io 测试可以作为模板。

  3. 服务端核心。 在 tests/unit/<proto>/core.rs 中,通过 CoreHarness:跨事件拆分的头部只有在完整后才打开;错误凭据以 PermissionDenied 失败;开启嗅探时 IP 目标会保留前缀,并在得到主机名或 SNIFF_TIMEOUT 到期时打开;域名目标从不嗅探;中继在每个方向各自半关闭,第二个方向关闭时结束;ConnectFailed 按协议要求的方式结束连接;建立之前收到 Event::Deadline 会以握手超时失败;对 UDP,不完整的包帧会等待剩余部分。protocols/tests/unit/trojan/core.rs 对每一项都有一个测试。

  4. 流水线。 新建 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 }, _, _>,而不是手选的大小。

  5. App。 在构建器旁边的单元测试(app/tests/unit/inbound.rs、outbound.rs、transport.rs)中验证:最小配置能构建成功,每个必需设置都会被强制检查,未知设置会被拒绝,不支持的传输层或 security 组合会 fail closed,并给出指明修复方法的信息。断言错误文本中有辨识度的部分。

  6. 互通。 如果 Xray 或 Hysteria 实现了该协议,为 app 支持的每个方向各写一个 e2e_* 测试,通过 socks5_roundtrip 驱动并断言回显完全一致。如果适用 mux.cool 或 XUDP,在 e2e_xray_mux 中加一项。

  7. katana(如果它提供该协议)。 在 katana/tests/unit/inbound.rs 中添加节点构建测试,在 xray_interop 矩阵中加一项并同时断言流量上报;对于新的凭据形态,在 tests/unit/connector.rs 中添加准入测试。

  8. 门槛。 在装有 Go 的环境中运行完整门槛,对互通模块带上 --nocapture,并检查没有任何输出 SKIP。

  • 用 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 外,每个入站都运行在服务端运行时中。
  • 速率限制与计量:一个 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 中通过 Runtime harness 编写测试。身份变化需要一个 identity 断言和一个重新启动的测试;其他修改需要一个测试证明它在运行中的节点上生效,并且在构建监听器所用的内容都没有变化时保留已打开的连接。
  • 凭据日志:当测试输入格式错误的凭据时,断言错误信息不会回显它,就像 tests/unit/outbound.rs 中的 a_malformed_uuid_is_refused_without_echoing_it 那样。
  • 新的面板字段或节点类型:在 tests/unit/api/ 中添加解析测试;如果集成测试需要,在 tests/support/mod.rs 中添加配置 body 辅助函数。