diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..02f82d4 --- /dev/null +++ b/.dockerignore @@ -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.* diff --git a/cmd/nixmsg/healthcheck.go b/cmd/nixmsg/healthcheck.go index e61c8c5..43e0b7b 100644 --- a/cmd/nixmsg/healthcheck.go +++ b/cmd/nixmsg/healthcheck.go @@ -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) +} diff --git a/cmd/nixmsg/healthcheck_test.go b/cmd/nixmsg/healthcheck_test.go new file mode 100644 index 0000000..404856e --- /dev/null +++ b/cmd/nixmsg/healthcheck_test.go @@ -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) + } +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile index ca0dfd5..b65bf7d 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -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 diff --git a/deploy/config.docker.yaml b/deploy/config.docker.yaml new file mode 100644 index 0000000..ef764d1 --- /dev/null +++ b/deploy/config.docker.yaml @@ -0,0 +1,10 @@ +# Compose / 容器内使用的配置示例。业务端口可按需改;测试请用随机端口,不要多实例抢 7443。 +listen: ":7443" +admin_listen: "" +tls: + cert_file: "" + key_file: "" + allow_plaintext: true +data_dir: /data +log: + level: info diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml new file mode 100644 index 0000000..50a83dd --- /dev/null +++ b/deploy/docker-compose.yml @@ -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 diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 2726b75..3ff3a96 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -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 立即退出。 diff --git a/taskfiles/q.yml b/taskfiles/q.yml new file mode 100644 index 0000000..df5eda7 --- /dev/null +++ b/taskfiles/q.yml @@ -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 . diff --git a/test/chaos/README.md b/test/chaos/README.md new file mode 100644 index 0000000..04cc609 --- /dev/null +++ b/test/chaos/README.md @@ -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:") +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:/proxies \ + -H 'Content-Type: application/json' \ + -d '{"name":"q-nixmsg","listen":"0.0.0.0:0","upstream":"host.docker.internal:","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)。 diff --git a/test/chaos/compose.yml b/test/chaos/compose.yml new file mode 100644 index 0000000..fa304d5 --- /dev/null +++ b/test/chaos/compose.yml @@ -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" diff --git a/test/chaos/helper.go b/test/chaos/helper.go new file mode 100644 index 0000000..90e8c45 --- /dev/null +++ b/test/chaos/helper.go @@ -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) +} diff --git a/test/chaos/helper_test.go b/test/chaos/helper_test.go new file mode 100644 index 0000000..00b3070 --- /dev/null +++ b/test/chaos/helper_test.go @@ -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") + } +} diff --git a/test/chaos/netem/README.md b/test/chaos/netem/README.md new file mode 100644 index 0000000..b8a434f --- /dev/null +++ b/test/chaos/netem/README.md @@ -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 业务端口写死进脚本。 diff --git a/test/chaos/netem/apply-loss.sh b/test/chaos/netem/apply-loss.sh new file mode 100644 index 0000000..1178133 --- /dev/null +++ b/test/chaos/netem/apply-loss.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# 在 Linux 容器内执行:对指定网卡注入丢包。用法:apply-loss.sh +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" diff --git a/test/load/cmd/mqttbench/main.go b/test/load/cmd/mqttbench/main.go new file mode 100644 index 0000000..66b2516 --- /dev/null +++ b/test/load/cmd/mqttbench/main.go @@ -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: + } +} diff --git a/test/load/mqtt_bench.go b/test/load/mqtt_bench.go new file mode 100644 index 0000000..3d3df16 --- /dev/null +++ b/test/load/mqtt_bench.go @@ -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 +} diff --git a/test/load/mqtt_bench_test.go b/test/load/mqtt_bench_test.go new file mode 100644 index 0000000..8e90a65 --- /dev/null +++ b/test/load/mqtt_bench_test.go @@ -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") + } +} diff --git a/test/report/cmd/genreport/main.go b/test/report/cmd/genreport/main.go new file mode 100644 index 0000000..c775181 --- /dev/null +++ b/test/report/cmd/genreport/main.go @@ -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 [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) + } +} diff --git a/test/report/generate.go b/test/report/generate.go new file mode 100644 index 0000000..8693e1a --- /dev/null +++ b/test/report/generate.go @@ -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, + } +} diff --git a/test/report/generate_test.go b/test/report/generate_test.go new file mode 100644 index 0000000..0a37614 --- /dev/null +++ b/test/report/generate_test.go @@ -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") + } +} diff --git a/test/report/testdata/sample_results.json b/test/report/testdata/sample_results.json new file mode 100644 index 0000000..b62c8b4 --- /dev/null +++ b/test/report/testdata/sample_results.json @@ -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": ""} + ] +}