feat: 测试交付 Q1 混沌压测骨架与 Q4 镜像骨架
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
# 加快构建,并避免把本机 node_modules(Windows)盖掉镜像内安装结果
|
||||
.git
|
||||
.cursor
|
||||
aidocs
|
||||
bin
|
||||
**/node_modules
|
||||
web/dist
|
||||
**/*.exe
|
||||
**/*.test
|
||||
**/*.db
|
||||
**/*.db-shm
|
||||
**/*.db-wal
|
||||
data
|
||||
config.yaml
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
.env
|
||||
.env.*
|
||||
+51
-13
@@ -3,36 +3,74 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
)
|
||||
|
||||
// healthcheck 请求本机 /healthz,成功退出 0。给 Docker HEALTHCHECK 用。
|
||||
func cmdHealthcheck(_ []string) error {
|
||||
cfg, err := loadAndValidateConfig()
|
||||
cfgPath := config.PathFromEnv()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
// 无配置时仍尝试 data_dir 默认值下的 listen.addr
|
||||
cfg = config.Default()
|
||||
}
|
||||
addr, err := resolveHealthAddr(cfg.DataDir, cfg.Listen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
addrBytes, err := os.ReadFile(filepath.Join(cfg.DataDir, "listen.addr"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read listen.addr: %w", err)
|
||||
}
|
||||
addr := strings.TrimSpace(string(addrBytes))
|
||||
if addr == "" {
|
||||
return fmt.Errorf("listen.addr is empty")
|
||||
}
|
||||
url := "http://" + addr + "/healthz"
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
client := &http.Client{Timeout: 3 * time.Second}
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("healthcheck %s: %w", url, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("healthz status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
return fmt.Errorf("healthcheck %s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolveHealthAddr(dataDir, listen string) (string, error) {
|
||||
path := filepath.Join(dataDir, "listen.addr")
|
||||
if b, err := os.ReadFile(path); err == nil {
|
||||
addr := strings.TrimSpace(string(b))
|
||||
if addr != "" {
|
||||
return rewriteLoopback(addr), nil
|
||||
}
|
||||
}
|
||||
if listen == "" {
|
||||
return "", fmt.Errorf("no listen.addr and empty listen")
|
||||
}
|
||||
host, port, err := net.SplitHostPort(listen)
|
||||
if err != nil {
|
||||
// listen 可能是 ":7443"
|
||||
if strings.HasPrefix(listen, ":") {
|
||||
return "127.0.0.1" + listen, nil
|
||||
}
|
||||
return "", fmt.Errorf("parse listen %q: %w", listen, err)
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port), nil
|
||||
}
|
||||
|
||||
func rewriteLoopback(addr string) string {
|
||||
host, port, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return addr
|
||||
}
|
||||
if host == "" || host == "0.0.0.0" || host == "::" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveHealthAddrFromListenAddrFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "listen.addr"), []byte("0.0.0.0:19100\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := resolveHealthAddr(dir, ":7443")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "127.0.0.1:19100" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveHealthAddrFromListen(t *testing.T) {
|
||||
got, err := resolveHealthAddr(t.TempDir(), ":9555")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got != "127.0.0.1:9555" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCmdHealthcheckOK(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
dir := t.TempDir()
|
||||
// httptest URL is like http://127.0.0.1:port — write host:port into listen.addr
|
||||
hostPort := srv.Listener.Addr().String()
|
||||
if err := os.WriteFile(filepath.Join(dir, "listen.addr"), []byte(hostPort+"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(cfgPath, []byte("listen: \":0\"\ndata_dir: \""+filepath.ToSlash(dir)+"\"\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("NIXMSG_CONFIG", cfgPath)
|
||||
if err := cmdHealthcheck(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
# 多阶段骨架:完整多架构发布见 Q4。
|
||||
# 语法阶段:Node 构建前端 → Go 编译(嵌入前端)→ distroless 运行。
|
||||
# 多阶段构建:Node 构建前端 → Go 编译(嵌入前端)→ distroless static nonroot。
|
||||
# 镜像名 git.asio.asia/nixevol/nixmsg;多架构 buildx 推送留给 Q4 定稿,本骨架先单架构可构建。
|
||||
|
||||
FROM node:24-bookworm AS web
|
||||
WORKDIR /src/web
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Compose / 容器内使用的配置示例。业务端口可按需改;测试请用随机端口,不要多实例抢 7443。
|
||||
listen: ":7443"
|
||||
admin_listen: ""
|
||||
tls:
|
||||
cert_file: ""
|
||||
key_file: ""
|
||||
allow_plaintext: true
|
||||
data_dir: /data
|
||||
log:
|
||||
level: info
|
||||
@@ -0,0 +1,31 @@
|
||||
# Docker Compose 示例(DEVELOPMENT 第 11.4 节骨架)
|
||||
# 本地冒烟:先 docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
|
||||
# 容器名带 q 前缀便于多 Agent 隔离;正式部署可去掉 container_name。
|
||||
|
||||
services:
|
||||
nixmsg:
|
||||
image: git.asio.asia/nixevol/nixmsg:0.1.0
|
||||
container_name: q-nixmsg
|
||||
restart: unless-stopped
|
||||
command: ["serve"]
|
||||
environment:
|
||||
NIXMSG_CONFIG: /etc/nixmsg/config.yaml
|
||||
ports:
|
||||
- "7443:7443"
|
||||
volumes:
|
||||
- ./config.docker.yaml:/etc/nixmsg/config.yaml:ro
|
||||
- q-nixmsg-data:/data
|
||||
# 生产环境挂载证书目录,例如:
|
||||
# - /opt/nixmsg/certs:/certs:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "/nixmsg", "healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
q-nixmsg-data:
|
||||
name: q-nixmsg-data
|
||||
|
||||
# 首次使用命名卷时,需保证 /data 对 uid 65532 可写,例如:
|
||||
# docker run --rm -v q-nixmsg-data:/data busybox chown -R 65532:65532 /data
|
||||
+57
-1
@@ -401,4 +401,60 @@
|
||||
|
||||
## 测试交付 Q
|
||||
|
||||
暂无。
|
||||
### Q1 / Q4 骨架 2026-09-30
|
||||
|
||||
1. **压测客户端未做到 1000 连接 / 10 分钟**
|
||||
- 原条款:TASKS Q1「1000 连接的压测客户端」;验证「能保持 1000 个连接」。
|
||||
- 实际做法:`test/load` 提供 MQTT 3.1.1 CONNECT/CONNACK 骨架与 `mqttbench` 命令,单元测试用进程内假 broker 验证多连接与连接数打印;未对真实 broker 压到 1000,也未跑 10 分钟吞吐。
|
||||
- 原因:本波只做 Q1 基础设施骨架;完整弱网/压测属 Q3,且当前 main 尚无完整 MQTT broker 业务。
|
||||
- 备选方案:立刻接 mochi 假 broker 或外部 mosquitto 压到 1000。
|
||||
- 影响:Q3 需补真实压测与报告数字。
|
||||
|
||||
2. **未做弱网压测与验收用例**
|
||||
- 原条款:TASKS Q2/Q3;本波总控指示「不要做弱网压测和验收用例」。
|
||||
- 实际做法:只交付 toxiproxy/netem 辅助、报告生成器与空结果样例;F01–F23 全部为未测。
|
||||
- 原因:波次范围。
|
||||
- 备选方案:无。
|
||||
- 影响:交付标准第 2、4 条待后续波次。
|
||||
|
||||
3. **为 Docker 健康检查在 `cmd/nixmsg` 增加 `healthcheck`**
|
||||
- 原条款:DEVELOPMENT 11.2/11.4;TASKS 第 5 节 `cmd/nixmsg` 属平台 P。
|
||||
- 实际做法:Q 线最小实现 `nixmsg healthcheck`(读 `listen.addr` 或配置 `listen`,请求本机 `/healthz`),否则 compose 健康检查无法按文档工作。
|
||||
- 原因:Q4 镜像骨架硬依赖该子命令;改动面小。
|
||||
- 备选方案:等 P 实现后再写 compose;或健康检查改用外部 curl(与 distroless 无 shell/curl 冲突)。
|
||||
- 影响:P 线后续可替换或扩展实现;注意勿重复注册同名命令。
|
||||
|
||||
4. **Docker 镜像仅当前架构骨架,不推送**
|
||||
- 原条款:DEVELOPMENT 11.4 多架构 `buildx` 推送;TASKS Q4 验证两个架构。
|
||||
- 实际做法:完善多阶段 Dockerfile + `deploy/docker-compose.yml`(镜像名 `git.asio.asia/nixevol/nixmsg`,健康检查 `/nixmsg healthcheck`);本机只 `docker build` 当前架构并冒烟 `/healthz`;不做 `docker push`、不做 arm64。
|
||||
- 原因:总控本波只要骨架;推送在 Z3。
|
||||
- 备选方案:本波强制 buildx 双架构(耗时长、非阻塞目标)。
|
||||
- 影响:多架构与仓库推送留到 Q4 定稿 / 阶段 3。
|
||||
|
||||
5. **compose 示例端口仍写 7443**
|
||||
- 原条款:测试隔离「不要写死 7443」;DEVELOPMENT 11.4 示例为 `7443:7443`。
|
||||
- 实际做法:`deploy/docker-compose.yml` 与文档示例一致使用 7443;混沌辅助与压测工具通过参数传入上游地址,不写死;本地冒烟可用其它宿主机端口映射。
|
||||
- 原因:部署示例需与 DEVELOPMENT 对齐;隔离约束针对并行测试而非产品默认端口。
|
||||
- 备选方案:compose 用变量 `${NIXMSG_HOST_PORT:-7443}`。
|
||||
- 影响:多 Agent 同时起官方 compose 会端口冲突,应改映射或错开项目名。
|
||||
|
||||
6. **toxiproxy 镜像与 netem 旁路镜像选型**
|
||||
- 原条款:用 toxiproxy 官方镜像;Linux 丢包用 netem。
|
||||
- 实际做法:`ghcr.io/shopify/toxiproxy:2.12.0`;netem 说明用 `nicolaka/netshoot` 挂脚本(需 `NET_ADMIN`)。
|
||||
- 原因:官方镜像无 tc;本机 Windows 不能本机 netem。
|
||||
- 备选方案:自建含 iproute2 的旁路镜像。
|
||||
- 影响:首次拉取 netshoot 需网络;脚本不进业务镜像。
|
||||
|
||||
7. **增加根目录 `.dockerignore`**
|
||||
- 原条款:未要求;Dockerfile 原为 `COPY web/` 在 `pnpm install` 之后。
|
||||
- 实际做法:忽略 `**/node_modules`、`web/dist`、`bin` 等,避免本机 Windows 的 `node_modules` 覆盖 Linux 安装结果导致 `vue-tsc` 找不到。
|
||||
- 原因:本机 `task check` 会生成 `web/node_modules`,直接进构建上下文会破坏前端阶段。
|
||||
- 备选方案:Dockerfile 在 `COPY web/` 后再跑一次 `pnpm install`。
|
||||
- 影响:镜像构建依赖 dockerignore;与任务目录无关的本地产物不再进上下文。
|
||||
|
||||
8. **命名卷首次需 chown 给 nonroot**
|
||||
- 原条款:DEVELOPMENT 11.4「挂载的数据目录要可写」,uid 65532。
|
||||
- 实际做法:冒烟前对命名卷执行 `chown -R 65532:65532 /data`;compose 未内置 init 容器。
|
||||
- 原因:空命名卷属主为 root 时,distroless nonroot 无法建库(`unable to open database file`)。
|
||||
- 备选方案:compose 增加一次性 init 服务;或文档要求宿主机目录预授权。
|
||||
- 影响:按示例首次 `up` 前需处理权限,否则 serve 立即退出。
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
version: "3"
|
||||
|
||||
tasks:
|
||||
q:chaos-test:
|
||||
desc: 运行混沌辅助单元测试
|
||||
cmds:
|
||||
- go test ./test/chaos/ -count=1 -v
|
||||
|
||||
q:report:
|
||||
desc: 从结果 JSON 生成 F01–F23 验收对照表(默认空样例)
|
||||
cmds:
|
||||
- go run ./test/report/cmd/genreport {{.CLI_ARGS}}
|
||||
|
||||
q:report-sample:
|
||||
desc: 用空结果样例生成对照表到 stdout
|
||||
cmds:
|
||||
- go run ./test/report/cmd/genreport ./test/report/testdata/sample_results.json
|
||||
|
||||
q:load-test:
|
||||
desc: 压测客户端骨架单元测试
|
||||
cmds:
|
||||
- go test ./test/load/ -count=1 -v
|
||||
|
||||
q:docker-build:
|
||||
desc: 构建当前架构镜像(不推送)
|
||||
cmds:
|
||||
- docker build -t git.asio.asia/nixevol/nixmsg:0.1.0 -f deploy/Dockerfile .
|
||||
@@ -0,0 +1,61 @@
|
||||
# 混沌 / 弱网测试辅助(Q 线)
|
||||
|
||||
用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。所有 Docker 资源名带 `q` 前缀,用完删除。
|
||||
|
||||
## 启动 toxiproxy
|
||||
|
||||
在仓库根目录(或本目录)执行:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml up -d
|
||||
```
|
||||
|
||||
API 默认映射到本机随机/固定端口见 compose 注释。查看实际端口:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml port toxiproxy 8474
|
||||
```
|
||||
|
||||
清理:
|
||||
|
||||
```powershell
|
||||
docker compose -p q-chaos -f test/chaos/compose.yml down -v
|
||||
```
|
||||
|
||||
## 对运行中的服务注入故障
|
||||
|
||||
不要写死 `7443`。上游地址取自当前实例的实际监听端口(例如测试 harness 写入的 `listen.addr`,或你启动时打印的地址)。
|
||||
|
||||
1. 本机起好 NixMsg(或任意 TCP 服务),记下 `HOST:PORT`。
|
||||
2. 在 Windows 上,容器访问本机服务用 `host.docker.internal:PORT`。
|
||||
3. 用本包客户端或 curl 建代理(listen 用 `0.0.0.0:0` 让 toxiproxy 分配端口):
|
||||
|
||||
```go
|
||||
c := chaos.NewClient("http://127.0.0.1:<api端口>")
|
||||
p, err := c.CreateProxy("q-nixmsg", "0.0.0.0:0", "host.docker.internal:"+strconv.Itoa(port))
|
||||
// 客户端改连 p.Listen(把 0.0.0.0 换成 127.0.0.1)
|
||||
_, _ = c.AddLatency("q-nixmsg", "lag", 200, 50, "")
|
||||
_, _ = c.AddResetPeer("q-nixmsg", "rst", 0, "")
|
||||
```
|
||||
|
||||
等价 curl:
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:<api>/proxies \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"name":"q-nixmsg","listen":"0.0.0.0:0","upstream":"host.docker.internal:<PORT>","enabled":true}'
|
||||
```
|
||||
|
||||
延迟:`POST .../proxies/q-nixmsg/toxics`,`type=latency`,`attributes.latency` / `jitter`(毫秒)。
|
||||
|
||||
断开:`type=reset_peer`,`attributes.timeout`(毫秒,0 表示尽快 RST)。
|
||||
|
||||
## 单元测试
|
||||
|
||||
```powershell
|
||||
go test ./test/chaos/ -count=1
|
||||
```
|
||||
|
||||
## netem 丢包
|
||||
|
||||
见 [netem/README.md](./netem/README.md)。脚本必须在 Linux 容器内执行(本机是 Windows)。
|
||||
@@ -0,0 +1,15 @@
|
||||
# toxiproxy:延迟与断开。项目名用 -p q-chaos,容器名带 q 前缀。
|
||||
# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。
|
||||
|
||||
services:
|
||||
toxiproxy:
|
||||
image: ghcr.io/shopify/toxiproxy:2.12.0
|
||||
container_name: q-toxiproxy
|
||||
# 8474=API;8475 起可手工映射代理端口,或在 CreateProxy 时用 0.0.0.0:0 再 docker port 查询
|
||||
ports:
|
||||
- "8474"
|
||||
- "8475"
|
||||
- "8476"
|
||||
# 允许代理到本机服务(Docker Desktop / Windows)
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
@@ -0,0 +1,192 @@
|
||||
// Package chaos 提供基于 toxiproxy 的弱网故障注入辅助。
|
||||
// 上游地址由调用方传入,不写死端口。
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client 调用 toxiproxy HTTP API。
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient 创建客户端。baseURL 形如 http://127.0.0.1:8474。
|
||||
func NewClient(baseURL string) *Client {
|
||||
return &Client{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
HTTPClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy 描述一个 toxiproxy 代理。
|
||||
type Proxy struct {
|
||||
Name string `json:"name"`
|
||||
Listen string `json:"listen"`
|
||||
Upstream string `json:"upstream"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Toxic 描述一条故障规则。
|
||||
type Toxic struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Stream string `json:"stream,omitempty"`
|
||||
Toxicity float32 `json:"toxicity,omitempty"`
|
||||
Attributes map[string]any `json:"attributes,omitempty"`
|
||||
}
|
||||
|
||||
// CreateProxy 创建或覆盖同名代理。listen 可用 host:0 让 toxiproxy 分配端口。
|
||||
func (c *Client) CreateProxy(name, listen, upstream string) (*Proxy, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("proxy name required")
|
||||
}
|
||||
if listen == "" {
|
||||
return nil, fmt.Errorf("listen required")
|
||||
}
|
||||
if upstream == "" {
|
||||
return nil, fmt.Errorf("upstream required")
|
||||
}
|
||||
body := Proxy{
|
||||
Name: name,
|
||||
Listen: listen,
|
||||
Upstream: upstream,
|
||||
Enabled: true,
|
||||
}
|
||||
var out Proxy
|
||||
if err := c.doJSON(http.MethodPost, "/proxies", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// DeleteProxy 删除代理;不存在时忽略。
|
||||
func (c *Client) DeleteProxy(name string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+name, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("delete proxy: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
// GetProxy 读取代理(含实际 listen 地址)。
|
||||
func (c *Client) GetProxy(name string) (*Proxy, error) {
|
||||
var out Proxy
|
||||
if err := c.doJSON(http.MethodGet, "/proxies/"+name, nil, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// AddLatency 注入下行/上行延迟(毫秒)。stream 为空时默认 downstream。
|
||||
func (c *Client) AddLatency(proxyName, toxicName string, latencyMs, jitterMs int, stream string) (*Toxic, error) {
|
||||
if stream == "" {
|
||||
stream = "downstream"
|
||||
}
|
||||
t := Toxic{
|
||||
Name: toxicName,
|
||||
Type: "latency",
|
||||
Stream: stream,
|
||||
Toxicity: 1,
|
||||
Attributes: map[string]any{
|
||||
"latency": latencyMs,
|
||||
"jitter": jitterMs,
|
||||
},
|
||||
}
|
||||
return c.addToxic(proxyName, t)
|
||||
}
|
||||
|
||||
// AddResetPeer 在连接上注入 TCP RST(断开)。timeoutMs 为触发前等待。
|
||||
func (c *Client) AddResetPeer(proxyName, toxicName string, timeoutMs int, stream string) (*Toxic, error) {
|
||||
if stream == "" {
|
||||
stream = "downstream"
|
||||
}
|
||||
t := Toxic{
|
||||
Name: toxicName,
|
||||
Type: "reset_peer",
|
||||
Stream: stream,
|
||||
Toxicity: 1,
|
||||
Attributes: map[string]any{
|
||||
"timeout": timeoutMs,
|
||||
},
|
||||
}
|
||||
return c.addToxic(proxyName, t)
|
||||
}
|
||||
|
||||
// RemoveToxic 删除一条 toxic。
|
||||
func (c *Client) RemoveToxic(proxyName, toxicName string) error {
|
||||
req, err := http.NewRequest(http.MethodDelete, c.BaseURL+"/proxies/"+proxyName+"/toxics/"+toxicName, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusOK {
|
||||
return nil
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("remove toxic: %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
|
||||
func (c *Client) addToxic(proxyName string, t Toxic) (*Toxic, error) {
|
||||
var out Toxic
|
||||
if err := c.doJSON(http.MethodPost, "/proxies/"+proxyName+"/toxics", t, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (c *Client) doJSON(method, path string, in any, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.BaseURL+path, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
if out == nil || len(respBody) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(respBody, out)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package chaos
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestClientCreateLatencyAndReset(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
toxics := map[string][]Toxic{}
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/proxies":
|
||||
var p Proxy
|
||||
if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
|
||||
t.Errorf("decode proxy: %v", err)
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if p.Listen == "0.0.0.0:0" {
|
||||
p.Listen = "0.0.0.0:18080"
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(p)
|
||||
|
||||
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/toxics"):
|
||||
var toxic Toxic
|
||||
if err := json.NewDecoder(r.Body).Decode(&toxic); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/proxies/"), "/toxics")
|
||||
toxics[name] = append(toxics[name], toxic)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(toxic)
|
||||
|
||||
case r.Method == http.MethodDelete:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
c := NewClient(srv.URL)
|
||||
p, err := c.CreateProxy("q-demo", "0.0.0.0:0", "127.0.0.1:19000")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProxy: %v", err)
|
||||
}
|
||||
if p.Upstream != "127.0.0.1:19000" {
|
||||
t.Fatalf("upstream = %q", p.Upstream)
|
||||
}
|
||||
if p.Listen != "0.0.0.0:18080" {
|
||||
t.Fatalf("listen = %q", p.Listen)
|
||||
}
|
||||
|
||||
lat, err := c.AddLatency("q-demo", "lag", 200, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("AddLatency: %v", err)
|
||||
}
|
||||
if lat.Type != "latency" {
|
||||
t.Fatalf("type = %q", lat.Type)
|
||||
}
|
||||
if _, err := c.AddResetPeer("q-demo", "drop", 0, ""); err != nil {
|
||||
t.Fatalf("AddResetPeer: %v", err)
|
||||
}
|
||||
if got := toxics["q-demo"]; len(got) != 2 {
|
||||
t.Fatalf("toxics len = %d", len(got))
|
||||
}
|
||||
if got := toxics["q-demo"][0]; got.Type != "latency" {
|
||||
t.Fatalf("first toxic type = %q", got.Type)
|
||||
}
|
||||
if err := c.RemoveToxic("q-demo", "lag"); err != nil {
|
||||
t.Fatalf("RemoveToxic: %v", err)
|
||||
}
|
||||
if err := c.DeleteProxy("q-demo"); err != nil {
|
||||
t.Fatalf("DeleteProxy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientRejectsEmptyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
c := NewClient("http://127.0.0.1:1")
|
||||
if _, err := c.CreateProxy("", "0.0.0.0:0", "127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected error for empty name")
|
||||
}
|
||||
if _, err := c.CreateProxy("x", "", "127.0.0.1:1"); err == nil {
|
||||
t.Fatal("expected error for empty listen")
|
||||
}
|
||||
if _, err := c.CreateProxy("x", "0.0.0.0:0", ""); err == nil {
|
||||
t.Fatal("expected error for empty upstream")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateProxyErrorStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
_, _ = io.WriteString(w, "already exists")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
c := NewClient(srv.URL)
|
||||
if _, err := c.CreateProxy("q", "0.0.0.0:0", "127.0.0.1:9"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
# Linux 容器内用 netem 做丢包(本机 Windows 不能直接跑 tc)
|
||||
|
||||
## 用法
|
||||
|
||||
对已经在同一网络里的目标容器网卡注入 20% 丢包(需要 `NET_ADMIN`):
|
||||
|
||||
```powershell
|
||||
# 示例:起一个带 net-tools/iproute2 的旁路容器,对网卡 eth0 丢包
|
||||
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:q-toxiproxy `
|
||||
nicolaka/netshoot `
|
||||
bash -lc "tc qdisc replace dev eth0 root netem loss 20%"
|
||||
```
|
||||
|
||||
或把本目录脚本挂进去:
|
||||
|
||||
```powershell
|
||||
docker run --rm --name q-netem --cap-add=NET_ADMIN --network container:<目标容器名> `
|
||||
-v ${PWD}/test/chaos/netem:/scripts:ro `
|
||||
nicolaka/netshoot `
|
||||
bash /scripts/apply-loss.sh eth0 20
|
||||
```
|
||||
|
||||
清除:
|
||||
|
||||
```powershell
|
||||
docker run --rm --name q-netem-clear --cap-add=NET_ADMIN --network container:<目标容器名> `
|
||||
nicolaka/netshoot `
|
||||
bash -lc "tc qdisc del dev eth0 root 2>/dev/null || true"
|
||||
```
|
||||
|
||||
用完删除容器(上面 `--rm` 已自动删)。不要把 NixMsg 业务端口写死进脚本。
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh <iface> <loss_percent>
|
||||
set -euo pipefail
|
||||
|
||||
IFACE="${1:-eth0}"
|
||||
LOSS="${2:-20}"
|
||||
|
||||
if ! command -v tc >/dev/null 2>&1; then
|
||||
echo "tc not found; use an image with iproute2 (e.g. nicolaka/netshoot)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tc qdisc replace dev "$IFACE" root netem loss "${LOSS}%"
|
||||
echo "netem: iface=${IFACE} loss=${LOSS}%"
|
||||
tc qdisc show dev "$IFACE"
|
||||
@@ -0,0 +1,40 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/load"
|
||||
)
|
||||
|
||||
// 压测客户端骨架:连上 N 个 MQTT 连接并打印连接数。完整 1000 连接 / 10 分钟压测留给 Q3。
|
||||
func main() {
|
||||
addr := flag.String("addr", "", "MQTT broker host:port(必填,不要写死业务端口)")
|
||||
n := flag.Int("n", 10, "连接数")
|
||||
prefix := flag.String("prefix", "q-bench-", "client id 前缀")
|
||||
hold := flag.Duration("hold", 3*time.Second, "保持连接时长")
|
||||
flag.Parse()
|
||||
if *addr == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: mqttbench -addr host:port [-n 10]")
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
b := &load.MQTTBench{Addr: *addr, ClientIDPrefix: *prefix, Timeout: 5 * time.Second}
|
||||
defer b.Close()
|
||||
if err := b.ConnectN(*n, os.Stdout); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("held %d connections for %s\n", b.Alive(), hold.String())
|
||||
|
||||
ch := make(chan os.Signal, 1)
|
||||
signal.Notify(ch, os.Interrupt, syscall.SIGTERM)
|
||||
select {
|
||||
case <-time.After(*hold):
|
||||
case <-ch:
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Package load 提供 MQTT 压测客户端骨架:先连上并统计连接数。
|
||||
package load
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MQTTBench 保持多个 MQTT 3.1.1 TCP 连接(仅 CONNECT/CONNACK)。
|
||||
type MQTTBench struct {
|
||||
Addr string
|
||||
ClientIDPrefix string
|
||||
Timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conns []net.Conn
|
||||
alive atomic.Int64
|
||||
}
|
||||
|
||||
// ConnectN 建立 n 条连接;成功一条 alive+1,并打印当前连接数到 w(可为 nil)。
|
||||
func (b *MQTTBench) ConnectN(n int, w io.Writer) error {
|
||||
if b.Addr == "" {
|
||||
return fmt.Errorf("addr required")
|
||||
}
|
||||
if n <= 0 {
|
||||
return fmt.Errorf("n must be > 0")
|
||||
}
|
||||
timeout := b.Timeout
|
||||
if timeout <= 0 {
|
||||
timeout = 5 * time.Second
|
||||
}
|
||||
prefix := b.ClientIDPrefix
|
||||
if prefix == "" {
|
||||
prefix = "q-bench-"
|
||||
}
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
conn, err := net.DialTimeout("tcp", b.Addr, timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %d: %w", i, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||
cid := fmt.Sprintf("%s%d", prefix, i)
|
||||
if err := mqttConnect(conn, cid); err != nil {
|
||||
_ = conn.Close()
|
||||
return fmt.Errorf("connect %d: %w", i, err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Time{})
|
||||
b.mu.Lock()
|
||||
b.conns = append(b.conns, conn)
|
||||
b.mu.Unlock()
|
||||
cur := b.alive.Add(1)
|
||||
if w != nil {
|
||||
_, _ = fmt.Fprintf(w, "mqtt connections: %d\n", cur)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Alive 当前仍打开的连接数。
|
||||
func (b *MQTTBench) Alive() int64 {
|
||||
return b.alive.Load()
|
||||
}
|
||||
|
||||
// Close 关闭全部连接。
|
||||
func (b *MQTTBench) Close() {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
for _, c := range b.conns {
|
||||
_ = c.Close()
|
||||
}
|
||||
b.conns = nil
|
||||
b.alive.Store(0)
|
||||
}
|
||||
|
||||
func mqttConnect(conn net.Conn, clientID string) error {
|
||||
pkt := buildConnect(clientID)
|
||||
if _, err := conn.Write(pkt); err != nil {
|
||||
return err
|
||||
}
|
||||
header := make([]byte, 4)
|
||||
if _, err := io.ReadFull(conn, header[:2]); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[0] != 0x20 {
|
||||
return fmt.Errorf("unexpected packet type 0x%02x", header[0])
|
||||
}
|
||||
// remaining length 对 CONNACK 固定为 2
|
||||
if header[1] != 2 {
|
||||
return fmt.Errorf("unexpected remaining length %d", header[1])
|
||||
}
|
||||
if _, err := io.ReadFull(conn, header[2:4]); err != nil {
|
||||
return err
|
||||
}
|
||||
if header[3] != 0 {
|
||||
return fmt.Errorf("CONNACK rc=%d", header[3])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildConnect(clientID string) []byte {
|
||||
// Variable header: protocol name MQTT, level 4, flags 0, keepalive 60
|
||||
vh := []byte{
|
||||
0x00, 0x04, 'M', 'Q', 'T', 'T',
|
||||
0x04,
|
||||
0x00, // clean session=0 flags for skeleton; brokers may still accept
|
||||
0x00, 0x3c,
|
||||
}
|
||||
// Actually clean session bit should be set for simple benches
|
||||
vh[7] = 0x02 // Clean Session
|
||||
|
||||
id := []byte(clientID)
|
||||
payload := make([]byte, 2+len(id))
|
||||
binary.BigEndian.PutUint16(payload[0:2], uint16(len(id)))
|
||||
copy(payload[2:], id)
|
||||
|
||||
remaining := len(vh) + len(payload)
|
||||
pkt := make([]byte, 0, 2+remaining)
|
||||
pkt = append(pkt, 0x10)
|
||||
pkt = append(pkt, encodeRemainingLength(remaining)...)
|
||||
pkt = append(pkt, vh...)
|
||||
pkt = append(pkt, payload...)
|
||||
return pkt
|
||||
}
|
||||
|
||||
func encodeRemainingLength(n int) []byte {
|
||||
var out []byte
|
||||
for {
|
||||
encoded := byte(n % 128)
|
||||
n /= 128
|
||||
if n > 0 {
|
||||
encoded |= 0x80
|
||||
}
|
||||
out = append(out, encoded)
|
||||
if n == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package load
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 极简 MQTT broker:读 CONNECT,回 CONNACK accepted,保持连接。
|
||||
func startFakeMQTTBroker(t *testing.T) (addr string, closeFn func()) {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
go func(c net.Conn) {
|
||||
defer func() { _ = c.Close() }()
|
||||
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 256)
|
||||
n, err := c.Read(buf)
|
||||
if err != nil || n < 2 || buf[0] != 0x10 {
|
||||
return
|
||||
}
|
||||
// CONNACK: type 0x20, remaining 2, flags 0, rc 0
|
||||
_, _ = c.Write([]byte{0x20, 0x02, 0x00, 0x00})
|
||||
_ = c.SetDeadline(time.Time{})
|
||||
_, _ = io.Copy(io.Discard, c)
|
||||
}(conn)
|
||||
}
|
||||
}()
|
||||
return ln.Addr().String(), func() {
|
||||
close(done)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTBenchConnectN(t *testing.T) {
|
||||
addr, stop := startFakeMQTTBroker(t)
|
||||
defer stop()
|
||||
|
||||
var out bytes.Buffer
|
||||
b := &MQTTBench{Addr: addr, ClientIDPrefix: "q-t-", Timeout: 2 * time.Second}
|
||||
defer b.Close()
|
||||
if err := b.ConnectN(3, &out); err != nil {
|
||||
t.Fatalf("ConnectN: %v", err)
|
||||
}
|
||||
if b.Alive() != 3 {
|
||||
t.Fatalf("alive=%d", b.Alive())
|
||||
}
|
||||
got := out.String()
|
||||
if !bytes.Contains(out.Bytes(), []byte("mqtt connections: 3")) {
|
||||
t.Fatalf("output missing count: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMQTTBenchRequiresAddr(t *testing.T) {
|
||||
b := &MQTTBench{}
|
||||
if err := b.ConnectN(1, nil); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/test/report"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: genreport <results.json> [out.md]")
|
||||
os.Exit(2)
|
||||
}
|
||||
r, err := report.LoadResults(os.Args[1])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
items, err := report.Normalize(r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
out := os.Stdout
|
||||
if len(os.Args) >= 3 {
|
||||
f, err := os.Create(os.Args[2])
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
out = f
|
||||
}
|
||||
if err := report.WriteMarkdown(out, items); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
// Package report 按 PRD 第 10 节 F01–F23 生成验收对照表。
|
||||
package report
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Status 验收结果。
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusPass Status = "pass"
|
||||
StatusFail Status = "fail"
|
||||
StatusUntested Status = "untested"
|
||||
)
|
||||
|
||||
// Item 单条验收结果。
|
||||
type Item struct {
|
||||
ID string `json:"id"`
|
||||
Status Status `json:"status"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
// Results 结果文件格式。
|
||||
type Results struct {
|
||||
GeneratedAt string `json:"generated_at,omitempty"`
|
||||
Items []Item `json:"items"`
|
||||
}
|
||||
|
||||
// Feature 验收总表一行。
|
||||
type Feature struct {
|
||||
ID string
|
||||
Summary string
|
||||
}
|
||||
|
||||
// Features 是 PRD 第 10 节 F01–F23。
|
||||
var Features = []Feature{
|
||||
{ID: "F01", Summary: "批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据"},
|
||||
{ID: "F02", Summary: "新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误"},
|
||||
{ID: "F03", Summary: "断开后状态及时变离线,全表可列出"},
|
||||
{ID: "F04", Summary: "只通知订阅了的端"},
|
||||
{ID: "F05", Summary: "崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效"},
|
||||
{ID: "F06", Summary: "群成员收到同一份,入群前不补,发送者不收到自己的"},
|
||||
{ID: "F07", Summary: "256 KiB 通过,超出拒绝,接收上限生效"},
|
||||
{ID: "F08", Summary: "弱网最终送达且应用层不重复,重启后续传"},
|
||||
{ID: "F09", Summary: "保留时间从发送时刻起算,超时过期"},
|
||||
{ID: "F10", Summary: "短断线送到,长断线丢弃,服务器重启后宽限内重连送到"},
|
||||
{ID: "F11", Summary: "发送方离线后到点仍发送"},
|
||||
{ID: "F12", Summary: "延迟窗口内撤回对方收不到"},
|
||||
{ID: "F13", Summary: "未推送必撤成功;群部分确认得到部分撤回"},
|
||||
{ID: "F14", Summary: "回执能补送给当时离线的发送方"},
|
||||
{ID: "F15", Summary: "输一次记住、改密失效、回复免密、进群仍要密码、防多账号轮流猜"},
|
||||
{ID: "F16", Summary: "群主权限、退出后不再收到、解散后同编号新群不收旧消息"},
|
||||
{ID: "F17", Summary: "后台管端、管注册、管群、查记录,响应里没有正文;API 令牌可用且不能越权"},
|
||||
{ID: "F18", Summary: "送达后正文消失;记录天数 0 时连记录消失;防重仍在"},
|
||||
{ID: "F19", Summary: "四种 SDK 通过同一清单"},
|
||||
{ID: "F20", Summary: "裸 MQTT 能登录、收、确认、发"},
|
||||
{ID: "F21", Summary: "默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口"},
|
||||
{ID: "F22", Summary: "初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取"},
|
||||
{ID: "F23", Summary: "注册开关、安全码校验、换码不影响已注册、输错锁定"},
|
||||
}
|
||||
|
||||
// LoadResults 读取结果 JSON;缺项视为未测。
|
||||
func LoadResults(path string) (Results, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Results{}, err
|
||||
}
|
||||
var r Results
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return Results{}, fmt.Errorf("parse results: %w", err)
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Normalize 把结果补全为 F01–F23;非法 status 报错。
|
||||
func Normalize(r Results) ([]Item, error) {
|
||||
byID := make(map[string]Item, len(r.Items))
|
||||
for _, it := range r.Items {
|
||||
id := strings.ToUpper(strings.TrimSpace(it.ID))
|
||||
st, err := parseStatus(it.Status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", id, err)
|
||||
}
|
||||
it.ID = id
|
||||
it.Status = st
|
||||
byID[id] = it
|
||||
}
|
||||
out := make([]Item, 0, len(Features))
|
||||
for _, f := range Features {
|
||||
if it, ok := byID[f.ID]; ok {
|
||||
out = append(out, it)
|
||||
continue
|
||||
}
|
||||
out = append(out, Item{ID: f.ID, Status: StatusUntested, Note: "结果文件未提供"})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseStatus(s Status) (Status, error) {
|
||||
switch Status(strings.ToLower(string(s))) {
|
||||
case StatusPass, "通过", "ok", "passed":
|
||||
return StatusPass, nil
|
||||
case StatusFail, "失败", "failed":
|
||||
return StatusFail, nil
|
||||
case StatusUntested, "未测", "skip", "skipped", "":
|
||||
return StatusUntested, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown status %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func statusCN(s Status) string {
|
||||
switch s {
|
||||
case StatusPass:
|
||||
return "通过"
|
||||
case StatusFail:
|
||||
return "失败"
|
||||
default:
|
||||
return "未测"
|
||||
}
|
||||
}
|
||||
|
||||
// WriteMarkdown 写出 F01–F23 对照表。
|
||||
func WriteMarkdown(w io.Writer, items []Item) error {
|
||||
summary := map[Status]int{}
|
||||
byID := make(map[string]Item, len(items))
|
||||
for _, it := range items {
|
||||
byID[it.ID] = it
|
||||
summary[it.Status]++
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "# NixMsg 验收对照表(PRD 第 10 节)\n\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "生成时间:%s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "汇总:通过 %d,失败 %d,未测 %d\n\n", summary[StatusPass], summary[StatusFail], summary[StatusUntested]); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "| 编号 | 一句话 | 结果 | 备注 |\n|---|---|---|---|\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range Features {
|
||||
it := byID[f.ID]
|
||||
note := it.Note
|
||||
if note == "" {
|
||||
note = "—"
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "| %s | %s | %s | %s |\n", f.ID, f.Summary, statusCN(it.Status), note); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EmptyResults 返回全部未测的样例。
|
||||
func EmptyResults() Results {
|
||||
items := make([]Item, 0, len(Features))
|
||||
for _, f := range Features {
|
||||
items = append(items, Item{ID: f.ID, Status: StatusUntested, Note: ""})
|
||||
}
|
||||
return Results{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
Items: items,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package report
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeFillsMissingAsUntested(t *testing.T) {
|
||||
t.Parallel()
|
||||
items, err := Normalize(Results{Items: []Item{
|
||||
{ID: "F01", Status: StatusPass, Note: "ok"},
|
||||
{ID: "f08", Status: "失败", Note: "flaky"},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 23 {
|
||||
t.Fatalf("len=%d", len(items))
|
||||
}
|
||||
if items[0].Status != StatusPass || items[0].Note != "ok" {
|
||||
t.Fatalf("F01=%+v", items[0])
|
||||
}
|
||||
if items[7].Status != StatusFail {
|
||||
t.Fatalf("F08=%+v", items[7])
|
||||
}
|
||||
if items[22].Status != StatusUntested {
|
||||
t.Fatalf("F23=%+v", items[22])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMarkdownSample(t *testing.T) {
|
||||
t.Parallel()
|
||||
path := filepath.Join("testdata", "sample_results.json")
|
||||
r, err := LoadResults(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, err := Normalize(r)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := WriteMarkdown(&buf, items); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
for _, id := range []string{"F01", "F12", "F23"} {
|
||||
if !strings.Contains(out, id) {
|
||||
t.Fatalf("missing %s in:\n%s", id, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, "未测") {
|
||||
t.Fatalf("expected 未测:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "通过 0,失败 0,未测 23") {
|
||||
t.Fatalf("summary mismatch:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyResultsRoundTrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "empty.json")
|
||||
raw, err := json.MarshalIndent(EmptyResults(), "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if writeErr := os.WriteFile(path, raw, 0o644); writeErr != nil {
|
||||
t.Fatal(writeErr)
|
||||
}
|
||||
r, err := LoadResults(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(r.Items) != 23 {
|
||||
t.Fatalf("items=%d", len(r.Items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatusRejectsUnknown(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := Normalize(Results{Items: []Item{{ID: "F01", Status: "maybe"}}}); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"generated_at": "2026-09-30T00:00:00Z",
|
||||
"items": [
|
||||
{"id": "F01", "status": "untested", "note": ""},
|
||||
{"id": "F02", "status": "untested", "note": ""},
|
||||
{"id": "F03", "status": "untested", "note": ""},
|
||||
{"id": "F04", "status": "untested", "note": ""},
|
||||
{"id": "F05", "status": "untested", "note": ""},
|
||||
{"id": "F06", "status": "untested", "note": ""},
|
||||
{"id": "F07", "status": "untested", "note": ""},
|
||||
{"id": "F08", "status": "untested", "note": ""},
|
||||
{"id": "F09", "status": "untested", "note": ""},
|
||||
{"id": "F10", "status": "untested", "note": ""},
|
||||
{"id": "F11", "status": "untested", "note": ""},
|
||||
{"id": "F12", "status": "untested", "note": ""},
|
||||
{"id": "F13", "status": "untested", "note": ""},
|
||||
{"id": "F14", "status": "untested", "note": ""},
|
||||
{"id": "F15", "status": "untested", "note": ""},
|
||||
{"id": "F16", "status": "untested", "note": ""},
|
||||
{"id": "F17", "status": "untested", "note": ""},
|
||||
{"id": "F18", "status": "untested", "note": ""},
|
||||
{"id": "F19", "status": "untested", "note": ""},
|
||||
{"id": "F20", "status": "untested", "note": ""},
|
||||
{"id": "F21", "status": "untested", "note": ""},
|
||||
{"id": "F22", "status": "untested", "note": ""},
|
||||
{"id": "F23", "status": "untested", "note": ""}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user