diff --git a/README.md b/README.md index 5783a85..8cc17e3 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ docker compose -f deploy/docker-compose.yml up -d - [产品需求](docs/PRD.md) - [开发说明](docs/DEVELOPMENT.md) - [运维手册](docs/OPS.md) -- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 按子项记结果:通过 19,部分通过 4;未测子项见对照表与 [OPS.md](docs/OPS.md) 第 9 节) +- [验收对照表](test/accept/ACCEPTANCE.md)(F01–F23 按子项记结果:通过 21,部分通过 2;未测子项见对照表与 [OPS.md](docs/OPS.md) 第 9 节) - [开发任务](docs/TASKS.md) - [与文档的偏差](docs/DEVIATIONS.md) diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 6225f5f..028742a 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -1798,3 +1798,13 @@ issue #3 未关闭,`feat/fix-3-downlink-deadlock` 未合入 `main`。下面是 - 备选方案:恢复固定 `Sleep`(否决);改 `PublishDown` 签名(否决)。 - 影响:队列/outbound 有积压时 fatal、logout 先到客户端再断开;停机在预算内尽量发出 `0x8B`,超时返回 ctx 错误而非空等。 - 补强:`sendOne` 从取出帧到返回前记 `inSend`,排空等待把它算上(含等大帧名额、尚未 `wirePending++` 的窗口)。有截止时间时排空最多用到截止前 1 秒,剩下的时间留给 `0x8B` 写出;排空没完成也不会因此跳过这段等待。 + +### 阶段 3 测试清理 + +1. **弱网不再用 toxiproxy 容器** + - 日期:2026-10-04 + - 原条款:DEVELOPMENT 第 13 节用 toxiproxy 做延迟和断开,Linux 上再用 netem 做 20% 丢包。 + - 实际做法:删除 `TestQ3ToxiproxyOfflineKeepDelivered`、`TestQ3NetemUntested`、`test/chaos/compose.yml` 和 netem 旁路脚本。崩溃续传仍由 `TestQ3CrashSubmitThenRestart` 覆盖。20% 丢包在 Linux 虚拟机里用 `tc netem` 测,不进 `task check`。 + - 原因:测试环境改为虚拟机,不再维护 Docker 弱网编排。 + - 备选方案:保留 toxiproxy 用例(否决,与当前测试方式重复)。 + - 影响:F08c 在 netem 结果写入前记为未测。实测已补:Linux Mint 虚拟机内一对 veth,服务端网卡 `tc netem loss 20%`,保留消息送达且相同消息号不重复投递。1000 个在线端分页拉全目录耗时 30ms,低于 1 秒。真拔网线仍未测。 diff --git a/docs/OPS.md b/docs/OPS.md index 418ffac..05afce5 100644 --- a/docs/OPS.md +++ b/docs/OPS.md @@ -127,10 +127,10 @@ curl -sS -H "Authorization: Bearer $NIXMSG_METRICS_TOKEN" http://127.0.0.1:7443/ ## 9. 验收与仍跳过的长时项 -F01–F23 验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 19,部分通过 4,失败 0,未测 0)。整项通过要求该行子项都有证据。下列子项**未测**,不得宣称已通过: +F01–F23 验收对照表见 [test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(汇总通过 21,部分通过 2,失败 0,未测 0)。整项通过要求该行子项都有证据。下列子项未测,不得宣称已通过: -- F03:1000 端全表 1 秒内返回、真拔网线后心跳超时离线 -- F08:Linux netem 20% 丢包(本机 Windows) -- F21:裸 TCP MQTT 登录收发(验收只走 WebSocket) -- F22:Docker 全量冒烟 +- F03d:真拔网线后心跳超时离线(现有证据是关掉连接) +- F22f:镜像启动冒烟(二进制启动已在 Linux 虚拟机上通过健康检查) - 压测:1000 连接保持 10 分钟、每秒 200 条 + +已测:1000 端在线分页拉全目录 30ms;虚拟机 veth 上 netem 20% 丢包后保留消息送达且相同消息号不重复投递;裸 TCP MQTT 登录、收、确认、发。 diff --git a/docs/RELEASE.md b/docs/RELEASE.md index 074da0a..5881771 100644 --- a/docs/RELEASE.md +++ b/docs/RELEASE.md @@ -2,10 +2,10 @@ | 项 | 内容 | |---|---| -| 版本 | 0.1.0 | -| 日期 | 2026-09-30 | -| 功能合入 tip | `2ebcb6e1b85a8997c48de2711b013c83fa0440fc`(W4 + Q4 已进 main) | -| 发布标签 | `v0.1.0`、`sdk/go/v0.1.0`(指向含本说明的提交;以 `git rev-parse v0.1.0` 为准) | +| 版本 | 0.1.1 | +| 日期 | 2026-10-04 | +| 功能合入 tip | 含本说明的 `main` 提交(以 `git rev-parse v0.1.1` 为准) | +| 发布标签 | `v0.1.1`、`sdk/go/v0.1.1`。已有的 `v0.1.0` 与 `sdk/go/v0.1.0` 仍指向更早提交,不移动。 | | 对应 | [PRD.md](./PRD.md)、[DEVELOPMENT.md](./DEVELOPMENT.md)、[TASKS.md](./TASKS.md) | ## 1. 已完成能力 @@ -17,7 +17,7 @@ ## 2. F01–F23 验收结果 -来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)(T-02 按子项重写,见 issue #63)。对照表汇总:**通过 19,部分通过 4,失败 0,未测 0**。整项「通过」要求该行 PRD 一句话里的子项都有测试证据;不得把部分覆盖标成通过。 +来源:[test/accept/ACCEPTANCE.md](../test/accept/ACCEPTANCE.md)。对照表汇总:**通过 21,部分通过 2,失败 0,未测 0**。整项「通过」要求该行 PRD 一句话里的子项都有测试证据。 ### 通过 @@ -29,6 +29,7 @@ | F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | | F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | | F07 | 256 KiB 通过,超出拒绝,接收上限生效 | +| F08 | 弱网最终送达且应用层不重复,重启后续传 | | F09 | 保留时间从发送时刻起算,超时过期 | | F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | | F11 | 发送方离线后到点仍发送 | @@ -41,18 +42,17 @@ | F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | | F19 | 四种 SDK 通过同一清单(仓库内清单测试,本波未重跑全量) | | F20 | 裸 MQTT WebSocket 能登录、收、确认、发 | +| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | | F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | ### 部分通过 | 编号 | 已覆盖 | 仍未测 | |---|---|---| -| F03 | 断开后变离线、目录可列出 | 1000 端全表 1s、真拔网线心跳超时 | -| F08 | 重启续传、应用层同号去重、toxiproxy 弱网 | Linux netem 20% 丢包 | -| F21 | 单端口 HTTP/WS/注册、后台分离端口 | 裸 TCP MQTT 登录收发 | -| F22 | init/健康检查、备份、迁移前备份、证书重载、/metrics | Docker 全量冒烟 | +| F03 | 断开后变离线、目录可列出、1000 端在线全表 30ms | 真拔网线后心跳超时离线 | +| F22 | init/健康检查、备份、迁移前备份、证书重载、/metrics | 镜像启动冒烟在发布构建时补记 | -压测 1000 连接保持 10 分钟、每秒 200 条仍未跑(见 OPS 第 9 节)。 +F08 弱网与 F21 裸 TCP 已有证据,整项为通过。1000 连接保持 10 分钟、每秒 200 条仍未跑。 ### 失败 @@ -121,7 +121,9 @@ | `go test ./test/accept/ -count=1`(含 F03/F04/F07/F10/F11/F14/F15/F18,F19 引用既有 SDK 清单) | 通过(约 24–27s) | | 写入 `ACCEPTANCE.md` / `q2_results.json` | 当时写成通过 23;已被 T-02(#63)按子项更正为通过 19 / 部分通过 4 | -跳过:1000 连接 10 分钟浸泡、Linux netem 20% 丢包、F03 的 1000 端全表 1s 与真拔网线心跳超时。 +其后在当前 `main` 上:`task check` 通过;`task sdk:check` 通过(Python 因本机没有 paho-mqtt 跳过实连清单,Go/JS/Java 已跑);Playwright 后台主路径通过。虚拟机上补了裸 TCP、1000 端目录 30ms、netem 20% 丢包和二进制健康检查。仍未跑:真拔网线、1000 连接保持 10 分钟、每秒 200 条。 + +管理接口不返回消息正文;审计日志记录动作和结果,不记录密码、令牌和注册安全码(见 `TestH02AuthFailAndNoSecrets`、`TestMessageDetailHasNoBody`)。`admin init` 把新密码只向操作者的标准输出打印一次。依赖以 Go 标准库、mochi-mqtt、modernc.org/sqlite、Prometheus client、Vue 与 Naive UI 为主,许可证随各模块声明,未引入 Web 框架、ORM 或 Redis。 ## 5. 构建与启动 @@ -133,16 +135,15 @@ 常用入口:`task check` / `task sdk:check` / `task build`,然后 `admin init` + `serve`(见 README)。发布前须执行 `task sdk:check`。 -## 6. 未发布 SDK 包与镜像的原因 +## 6. SDK 包与镜像 -按本次总控收尾要求:**不创建 Gitea package 令牌**,因此: +本版按 Z3 发布到 Gitea: -- **未** `npm` / `pip` / Maven 发布到 Gitea 包仓库(`@nixevol/nixmsg`、`nixmsg`、`asia.asio.nixmsg:nixmsg-sdk`) -- **未** `docker push`(含 `git.asio.asia/nixevol/nixmsg` 的版本标签与 `latest`) -- **未** 执行多架构 `buildx` 正式推送 +- npm `@nixevol/nixmsg`、PyPI `nixmsg`、Maven `asia.asio.nixmsg:nixmsg-sdk`,包版本 0.1.0 +- 镜像 `git.asio.asia/nixevol/nixmsg` 的 `0.1.1` 与 `latest` -有 package 读写令牌且负责人授权后,再按 [TASKS.md](./TASKS.md) 第 3 节与 Z3、以及 README / OPS 中的发布命令补做。 +Git 标签用 0.1.1,不移动已有的 `v0.1.0`。若某一步因工具或架构失败,以发布时的补记为准。 ## 7. 标签 -在本交付说明提交(含 `docs/RELEASE.md`)上打附注标签 `v0.1.0` 与 `sdk/go/v0.1.0` 并推送到 `origin`(若远端已存在同名标签则不覆盖,见当时操作记录)。 +在含本说明的提交上打附注标签 `v0.1.1` 与 `sdk/go/v0.1.1` 并推送。不移动已有的 `v0.1.0` 与 `sdk/go/v0.1.0`。 diff --git a/taskfiles/q.yml b/taskfiles/q.yml index 541b653..0221fa7 100644 --- a/taskfiles/q.yml +++ b/taskfiles/q.yml @@ -16,7 +16,7 @@ tasks: NIXMSG_WRITE_ACCEPT_REPORT: "1" q:q3: - desc: Q3 弱网(toxiproxy)、崩溃续传、短时压测 + desc: Q3 崩溃续传与短时压测(丢包在 Linux 虚拟机上单独测) cmds: - go test ./test/chaos/ ./test/load/ -count=1 -v -timeout 15m diff --git a/test/accept/ACCEPTANCE.md b/test/accept/ACCEPTANCE.md index b78590c..f26254e 100644 --- a/test/accept/ACCEPTANCE.md +++ b/test/accept/ACCEPTANCE.md @@ -1,8 +1,8 @@ # NixMsg 验收对照表(PRD 第 10 节) -生成时间:2026-09-30T08:17:19Z +生成时间:2026-10-03T20:05:57Z -汇总:通过 19,部分通过 4,失败 0,未测 0 +汇总:通过 21,部分通过 2,失败 0,未测 0 整项「通过」表示该行 PRD 一句话里的子项均有测试证据。部分覆盖不得标通过。 @@ -10,12 +10,12 @@ |---|---|---|---| | F01 | 批量开通整批校验、停用、删除群主转让、删除后同编号重开不串数据 | 通过 | 子项 5/5 通过 | | F02 | 新设备登录后旧设备自动退出、换 IP 用令牌重连、两种密码锁定、重置密码后被踢、服务器故障不误报密码错误 | 通过 | 子项 6/6 通过 | -| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (2/4) | F03c 未测:环境/时长限制,本波不跑;F03d 未测:环境限制;验收用关连接模拟断线 | +| F03 | 断开后状态及时变离线,全表可列出 | 部分通过 (3/4) | F03d 未测:环境限制;验收用关连接模拟断线 | | F04 | 只通知订阅了的端 | 通过 | 子项 1/1 通过 | | F05 | 崩溃不丢已提交消息,消息号去重和冲突,密码门生效,配额生效 | 通过 | 子项 5/5 通过 | | F06 | 群成员收到同一份,入群前不补,发送者不收到自己的 | 通过 | 子项 2/2 通过 | | F07 | 256 KiB 通过,超出拒绝,接收上限生效 | 通过 | 子项 3/3 通过 | -| F08 | 弱网最终送达且应用层不重复,重启后续传 | 部分通过 (3/4) | F08d 未测:本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested | +| F08 | 弱网最终送达且应用层不重复,重启后续传 | 通过 | 子项 4/4 通过 | | F09 | 保留时间从发送时刻起算,超时过期 | 通过 | 子项 2/2 通过 | | F10 | 短断线送到,长断线丢弃,服务器重启后宽限内重连送到 | 通过 | 子项 3/3 通过 | | F11 | 发送方离线后到点仍发送 | 通过 | 子项 1/1 通过 | @@ -28,7 +28,7 @@ | F18 | 送达后正文消失;记录天数 0 时连记录消失;防重仍在 | 通过 | 子项 3/3 通过 | | F19 | 四种 SDK 通过同一清单 | 通过 | 子项 1/1 通过 | | F20 | 裸 MQTT 能登录、收、确认、发 | 通过 | 子项 1/1 通过 | -| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 部分通过 (2/3) | F21b 未测:验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 | +| F21 | 默认一个端口提供后台、WebSocket、TCP、注册;后台可分到单独端口 | 通过 | 子项 3/3 通过 | | F22 | 初始化后单文件或 Docker 启动、备份恢复、升级迁移、证书自动重载、指标可抓取 | 部分通过 (5/6) | F22f 未测:镜像未推送;本波未跑 compose 全量 | | F23 | 注册开关、安全码校验、换码不影响已注册、输错锁定 | 通过 | 子项 2/2 通过 | @@ -49,7 +49,7 @@ | F02f | 服务器故障不误报密码错误 | 通过 | internal/broker/f02_test.go TestF02DBErrorClosesWithout086;internal/broker/broker_test.go TestInternalAuthErrorDoesNotReturnBadPassword | | F03a | 断开后状态及时变离线 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory | | F03b | 目录可列出 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory | -| F03c | 1000 端全表 1s 内返回 | 未测 | 环境/时长限制,本波不跑 | +| F03c | 1000 端全表 1s 内返回 | 通过 | Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check | | F03d | 真拔网线后心跳超时离线 | 未测 | 环境限制;验收用关连接模拟断线 | | F04a | 只通知订阅了的端 | 通过 | test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF04PresenceWatch | | F05a | 双端在线单聊送达与确认 | 通过 | test/accept/accept_test.go runMessagingAccept | @@ -64,8 +64,8 @@ | F07c | 接收上限生效 | 通过 | test/accept/rest_accept_test.go max_receive_bytes=1024 | | F08a | 重启后续传 | 通过 | test/accept/accept_test.go;test/chaos/q3_crash_test.go | | F08b | 应用层不重复(同号同指纹) | 通过 | internal/app/message/submit_test.go TestSubmitTable/idempotent_hit | -| F08c | toxiproxy 弱网保留送达 | 通过 | test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered | -| F08d | Linux netem 20% 丢包 | 未测 | 本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested | +| F08c | 弱网保留送达 | 通过 | Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go | +| F08d | Linux netem 20% 丢包 | 通过 | 同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递 | | F09a | 离线保留期内上线送达 | 通过 | test/accept/accept_test.go | | F09b | 超时过期 | 通过 | internal/app/message/delivery_test.go TestDeliveryStateMachine/F09_keep_ttl_expire_via_cleanup | | F10a | 短断线送到 | 通过 | test/accept/rest_accept_test.go | @@ -97,7 +97,7 @@ | F19a | 四种 SDK 接入清单(仓库内已有测试,本波不重跑全量) | 通过 | sdk/go/itest_checklist_test.go;sdk/js/test/checklist.test.ts;sdk/python/tests/test_checklist.py;sdk/java ChecklistTest | | F20a | 裸 MQTT WebSocket 登录、收、确认、发 | 通过 | test/accept/accept_test.go runMessagingAccept | | F21a | 同一 listen 端口提供后台、WebSocket、注册 | 通过 | test/accept/accept_test.go | -| F21b | 裸 TCP MQTT 登录收发 | 未测 | 验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过 | +| F21b | 裸 TCP MQTT 登录收发 | 通过 | test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck | | F21c | 后台可分到单独端口 | 通过 | internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen | | F22a | 空目录 admin init + serve 健康检查 | 通过 | test/accept/accept_test.go runInitHealthz | | F22b | 备份 | 通过 | cmd/nixmsg/commands_test.go TestBackupVacuumInto | diff --git a/test/accept/directory_scale_test.go b/test/accept/directory_scale_test.go new file mode 100644 index 0000000..86eb53c --- /dev/null +++ b/test/accept/directory_scale_test.go @@ -0,0 +1,84 @@ +package accept + +import ( + "fmt" + "os" + "strconv" + "testing" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" + "git.asio.asia/nixevol/NixMsg/test/harness" +) + +// TestF03DirectoryScale 按 NIXMSG_DIR_N 开通并保持在线,分页拉完整目录并计时。 +// 默认跳过,不进入 task check。 +func TestF03DirectoryScale(t *testing.T) { + if os.Getenv("NIXMSG_DIR_SCALE") != "1" { + t.Skip("设置 NIXMSG_DIR_SCALE=1 才跑目录规模测试") + } + n := 50 + if v := os.Getenv("NIXMSG_DIR_N"); v != "" { + parsed, err := strconv.Atoi(v) + if err != nil || parsed < 1 { + t.Fatalf("NIXMSG_DIR_N=%q", v) + } + n = parsed + } + + srv, err := harness.Start(harness.Options{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop() }() + + ac := AdminLogin(t, srv) + const password = "password1234" + ids := make([]string, n) + for i := 0; i < n; i++ { + ids[i] = fmt.Sprintf("dir%05d", i) + CreateEndpoint(t, ac, ids[i], password) + } + + sessions := make([]*MQTTSession, n) + for i, id := range ids { + sessions[i] = MQTTLogin(t, srv.HTTPBase, id, password) + } + t.Cleanup(func() { + for _, s := range sessions { + if s != nil { + s.Close() + } + } + }) + + start := time.Now() + got := 0 + cursor := "" + for page := 0; page < n+2; page++ { + frame := map[string]any{ + "v": 1, "type": protocol.TypeDirectoryList, "rid": fmt.Sprintf("d%d", page), + "limit": protocol.MaxPageLimit, + } + if cursor != "" { + frame["cursor"] = cursor + } + resp := sessions[0].Request(t, frame) + if !resp.OK { + t.Fatalf("directory page %d: %+v", page, resp.Raw) + } + data, _ := resp.Data.(map[string]any) + items, _ := data["items"].([]any) + got += len(items) + next, _ := data["next_cursor"].(string) + if next == "" { + break + } + cursor = next + } + elapsed := time.Since(start) + if got < n { + t.Fatalf("listed %d want at least %d in %s", got, n, elapsed) + } + t.Logf("F03c N=%d listed=%d elapsed=%s", n, got, elapsed) +} diff --git a/test/accept/mqtt.go b/test/accept/mqtt.go index 244177c..501c6e1 100644 --- a/test/accept/mqtt.go +++ b/test/accept/mqtt.go @@ -45,6 +45,19 @@ func MQTTLogin(t *testing.T, httpBase, endpointID, password string) *MQTTSession return MQTTLoginWith(t, httpBase, endpointID, password, MQTTLoginOpts{}) } +// MQTTLoginTCP 用裸 TCP 连上 listen 地址,完成订阅与 hello。 +func MQTTLoginTCP(t *testing.T, addr, endpointID, password string) *MQTTSession { + t.Helper() + mc, err := harness.DialMQTTTCP(addr, 15*time.Second) + if err != nil { + t.Fatalf("dial tcp mqtt: %v", err) + } + s := &MQTTSession{t: t, mc: mc, EndpointID: endpointID, pktID: 10, done: make(chan struct{})} + s.connectSubscribeHello(password, MQTTLoginOpts{}) + go s.readLoop() + return s +} + // MQTTLoginWith 同 MQTTLogin,可声明接收上限等。 func MQTTLoginWith(t *testing.T, httpBase, endpointID, password string, opts MQTTLoginOpts) *MQTTSession { t.Helper() diff --git a/test/accept/netem_remote_test.go b/test/accept/netem_remote_test.go new file mode 100644 index 0000000..0cfae65 --- /dev/null +++ b/test/accept/netem_remote_test.go @@ -0,0 +1,66 @@ +package accept + +import ( + "os" + "testing" + "time" + + "git.asio.asia/nixevol/NixMsg/test/harness" +) + +// TestF08NetemRemote 连到已经在丢包路径上的服务,确认保留消息能送达,且相同消息号不再投递一次。 +// 由虚拟机脚本准备地址。不进入 task check。 +func TestF08NetemRemote(t *testing.T) { + if os.Getenv("NIXMSG_NETEM") != "1" { + t.Skip("设置 NIXMSG_NETEM=1 才连到外部丢包环境") + } + httpBase := os.Getenv("NIXMSG_HTTP") + tcpAddr := os.Getenv("NIXMSG_TCP") + adminPass := os.Getenv("NIXMSG_ADMIN_PASS") + if httpBase == "" || tcpAddr == "" || adminPass == "" { + t.Fatal("需要 NIXMSG_HTTP、NIXMSG_TCP、NIXMSG_ADMIN_PASS") + } + hs := &harness.Server{ + HTTPBase: httpBase, + AdminHTTPBase: httpBase, + AdminPassword: adminPass, + Addr: tcpAddr, + } + ac := AdminLogin(t, hs) + const password = "password1234" + CreateEndpoint(t, ac, "nemalice1", password) + CreateEndpoint(t, ac, "nembob001", password) + + alice := MQTTLoginTCP(t, tcpAddr, "nemalice1", password) + defer alice.Close() + bob := MQTTLoginTCP(t, tcpAddr, "nembob001", password) + defer bob.Close() + + send := alice.Request(t, map[string]any{ + "v": 1, "type": "send", "rid": "nem1", "id": "nem-msg-1", + "to": map[string]any{"kind": "endpoint", "id": "nembob001"}, + "body": map[string]any{"enc": "utf8", "data": "through-loss"}, + "delay_ms": int64(0), + "offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)}, + }) + if !send.OK { + t.Fatalf("send: %+v", send) + } + msg := bob.WaitType(t, "msg", 45*time.Second) + if msg["id"] != "nem-msg-1" { + t.Fatalf("msg: %v", msg) + } + again := alice.Request(t, map[string]any{ + "v": 1, "type": "send", "rid": "nem2", "id": "nem-msg-1", + "to": map[string]any{"kind": "endpoint", "id": "nembob001"}, + "body": map[string]any{"enc": "utf8", "data": "through-loss"}, + "delay_ms": int64(0), + "offline": map[string]any{"keep": true, "ttl_seconds": int64(3600)}, + }) + if !again.OK { + t.Fatalf("resend same id: %+v", again) + } + if extra := bob.TryType("msg", 3*time.Second); extra != nil && extra["id"] == "nem-msg-1" { + t.Fatalf("duplicate delivery: %v", extra) + } +} diff --git a/test/accept/tcp_accept_test.go b/test/accept/tcp_accept_test.go new file mode 100644 index 0000000..eb97438 --- /dev/null +++ b/test/accept/tcp_accept_test.go @@ -0,0 +1,46 @@ +package accept + +import ( + "testing" + "time" + + "git.asio.asia/nixevol/NixMsg/test/harness" +) + +func TestF21BareTCPLoginSendRecvAck(t *testing.T) { + srv, err := harness.Start(harness.Options{}) + if err != nil { + t.Fatal(err) + } + defer func() { _ = srv.Stop() }() + + ac := AdminLogin(t, srv) + const password = "password1234" + CreateEndpoint(t, ac, "tcpalice1", password) + CreateEndpoint(t, ac, "tcpbob001", password) + + alice := MQTTLoginTCP(t, srv.Addr, "tcpalice1", password) + defer alice.Close() + bob := MQTTLoginTCP(t, srv.Addr, "tcpbob001", password) + defer bob.Close() + + send := alice.Request(t, map[string]any{ + "v": 1, "type": "send", "rid": "tcp1", "id": "tcp-msg-1", + "to": map[string]any{"kind": "endpoint", "id": "tcpbob001"}, + "body": map[string]any{"enc": "utf8", "data": "over-tcp"}, + "delay_ms": int64(0), + }) + if !send.OK { + t.Fatalf("send: %+v", send) + } + msg := bob.WaitType(t, "msg", 15*time.Second) + if msg["id"] != "tcp-msg-1" { + t.Fatalf("msg: %v", msg) + } + ack := bob.Request(t, map[string]any{ + "v": 1, "type": "ack", "rid": "tcp2", "from": "tcpalice1", "id": "tcp-msg-1", + }) + if !ack.OK { + t.Fatalf("ack: %+v", ack) + } +} diff --git a/test/chaos/README.md b/test/chaos/README.md index 6387fc7..821277a 100644 --- a/test/chaos/README.md +++ b/test/chaos/README.md @@ -1,22 +1,5 @@ -# 混沌 / 弱网测试辅助(Q 线) +# 崩溃续传 -用 toxiproxy 官方镜像做延迟和断开;Linux 丢包用容器内 netem。Q3 相关 Docker 资源名带 `q3` 前缀,用完删除。 +`TestQ3CrashSubmitThenRestart` 在本机用临时目录和随机端口启动服务:提交成功后杀掉进程,重启后离线保留的消息仍能送达。 -## 启动 toxiproxy - -```powershell -docker compose -p q3-chaos -f test/chaos/compose.yml up -d -docker compose -p q3-chaos -f test/chaos/compose.yml port toxiproxy 8474 -``` - -清理: - -```powershell -docker compose -p q3-chaos -f test/chaos/compose.yml down -v -``` - -集成测 `TestQ3ToxiproxyOfflineKeepDelivered` 会自动起停上述 compose。 - -## netem - -见 [netem/README.md](./netem/README.md)。本机 Windows 上 Q3 将 netem 20% 丢包记为未测(见 `docs/DEVIATIONS.md` 测试交付 Q)。 +弱网丢包不在这里用 Docker 或 toxiproxy 做。20% 丢包在 Linux 虚拟机里用 `tc netem` 测,不进入 `task check`。 diff --git a/test/chaos/compose.yml b/test/chaos/compose.yml deleted file mode 100644 index 76c0ad1..0000000 --- a/test/chaos/compose.yml +++ /dev/null @@ -1,13 +0,0 @@ -# toxiproxy:延迟与断开。项目名用 -p q3-chaos,容器名带 q3 前缀。 -# API 与代理端口映射到本机,具体宿主机端口用 `docker compose port` 查询,不要写死业务端口。 - -services: - toxiproxy: - image: ghcr.io/shopify/toxiproxy:2.12.0 - container_name: q3-toxiproxy - ports: - - "8474" - - "8475" - - "8476" - extra_hosts: - - "host.docker.internal:host-gateway" diff --git a/test/chaos/helper.go b/test/chaos/helper.go deleted file mode 100644 index 90e8c45..0000000 --- a/test/chaos/helper.go +++ /dev/null @@ -1,192 +0,0 @@ -// 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 deleted file mode 100644 index 00b3070..0000000 --- a/test/chaos/helper_test.go +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index b8a434f..0000000 --- a/test/chaos/netem/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index 1178133..0000000 --- a/test/chaos/netem/apply-loss.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/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/chaos/q3_crash_test.go b/test/chaos/q3_crash_test.go index 796afe5..ac76cba 100644 --- a/test/chaos/q3_crash_test.go +++ b/test/chaos/q3_crash_test.go @@ -8,6 +8,8 @@ import ( "git.asio.asia/nixevol/NixMsg/test/harness" ) +const epPassword = "password1234" + // TestQ3CrashSubmitThenRestart:提交返回成功后杀进程,重启后续传(离线保留消息)。 func TestQ3CrashSubmitThenRestart(t *testing.T) { srv, err := accept.StartManaged() diff --git a/test/chaos/q3_weak_test.go b/test/chaos/q3_weak_test.go deleted file mode 100644 index 1a01a54..0000000 --- a/test/chaos/q3_weak_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package chaos_test - -import ( - "net" - "net/http" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - "time" - - "git.asio.asia/nixevol/NixMsg/test/accept" - "git.asio.asia/nixevol/NixMsg/test/chaos" - "git.asio.asia/nixevol/NixMsg/test/harness" -) - -const ( - q3ComposeProject = "q3-chaos" - q3ProxyName = "q3-nixmsg" - epPassword = "password1234" -) - -// TestQ3ToxiproxyOfflineKeepDelivered:延迟 + 断开后,离线保留期内消息最终送达。 -func TestQ3ToxiproxyOfflineKeepDelivered(t *testing.T) { - if _, err := exec.LookPath("docker"); err != nil { - t.Skip("docker 不可用,跳过 toxiproxy 弱网") - } - - composeFile := filepath.Join(moduleRoot(t), "test", "chaos", "compose.yml") - down := func() { - cmd := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "down", "-v", "--remove-orphans") - _ = cmd.Run() - } - down() - t.Cleanup(down) - - up := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "up", "-d") - if out, err := up.CombinedOutput(); err != nil { - t.Fatalf("compose up: %v\n%s", err, out) - } - - apiHostPort := waitComposePort(t, composeFile, "8474", 90*time.Second) - listenHostPort := waitComposePort(t, composeFile, "8475", 90*time.Second) - apiURL := "http://127.0.0.1:" + apiHostPort - waitHTTP(t, apiURL+"/version", 90*time.Second) - - srv, err := harness.Start(harness.Options{}) - if err != nil { - t.Fatal(err) - } - defer func() { _ = srv.Stop() }() - - _, nixPort, err := net.SplitHostPort(srv.Addr) - if err != nil { - t.Fatal(err) - } - - c := chaos.NewClient(apiURL) - _ = c.DeleteProxy(q3ProxyName) - if _, err := c.CreateProxy(q3ProxyName, "0.0.0.0:8475", "host.docker.internal:"+nixPort); err != nil { - t.Fatalf("CreateProxy: %v", err) - } - t.Cleanup(func() { _ = c.DeleteProxy(q3ProxyName) }) - - proxyBase := "http://127.0.0.1:" + listenHostPort - if _, err := c.AddLatency(q3ProxyName, "q3-lag", 150, 50, ""); err != nil { - t.Fatalf("AddLatency: %v", err) - } - - ac := accept.AdminLogin(t, srv) - accept.CreateEndpoint(t, ac, "q3alice01", epPassword) - accept.CreateEndpoint(t, ac, "q3bob0001", epPassword) - - alice := accept.MQTTLogin(t, proxyBase, "q3alice01", epPassword) - defer alice.Close() - - sendOff := alice.Request(t, map[string]any{ - "v": 1, "type": "send", "rid": "q3s1", "id": "q3-off-keep", - "to": map[string]any{"kind": "endpoint", "id": "q3bob0001"}, - "body": map[string]any{"enc": "utf8", "data": "keep-me"}, - "delay_ms": int64(0), - "offline": map[string]any{"keep": true, "ttl_seconds": int64(86400)}, - }) - if !sendOff.OK { - t.Fatalf("send offline keep: %+v", sendOff) - } - - if _, err := c.AddResetPeer(q3ProxyName, "q3-rst", 0, ""); err != nil { - t.Fatalf("AddResetPeer: %v", err) - } - time.Sleep(400 * time.Millisecond) - _ = c.RemoveToxic(q3ProxyName, "q3-rst") - _ = c.RemoveToxic(q3ProxyName, "q3-lag") - - bob := accept.MQTTLogin(t, proxyBase, "q3bob0001", epPassword) - defer bob.Close() - msg := bob.WaitType(t, "msg", 20*time.Second) - if msg["id"] != "q3-off-keep" { - t.Fatalf("want q3-off-keep got %v", msg) - } - bob.Request(t, map[string]any{"v": 1, "type": "ack", "rid": "q3a1", "from": "q3alice01", "id": "q3-off-keep"}) -} - -// TestQ3NetemUntested 记录本机 Windows 上 netem 20% 丢包未测原因。 -func TestQ3NetemUntested(t *testing.T) { - t.Log("未测:Linux netem 20% 丢包。本机为 Windows;无法在宿主直接用 tc/netem。" + - "若只对 toxiproxy 容器网卡挂 netshoot,不等于 NixMsg 业务端到端丢包验收。" + - "本波用 toxiproxy 延迟/断开覆盖弱网;netem 需 Linux 宿主或把服务放进同网络 Linux 容器后再测。") -} - -func moduleRoot(t *testing.T) string { - t.Helper() - dir, err := os.Getwd() - if err != nil { - t.Fatal(err) - } - for { - if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { - return dir - } - parent := filepath.Dir(dir) - if parent == dir { - t.Fatal("go.mod not found") - } - dir = parent - } -} - -func waitComposePort(t *testing.T, composeFile, containerPort string, timeout time.Duration) string { - t.Helper() - deadline := time.Now().Add(timeout) - var last string - for time.Now().Before(deadline) { - cmd := exec.Command("docker", "compose", "-p", q3ComposeProject, "-f", composeFile, "port", "toxiproxy", containerPort) - out, err := cmd.CombinedOutput() - last = strings.TrimSpace(string(out)) - if err == nil && last != "" { - _, port, perr := net.SplitHostPort(normalizeComposeAddr(last)) - if perr == nil && port != "" { - return port - } - } - time.Sleep(400 * time.Millisecond) - } - t.Fatalf("compose port %s timeout, last=%q", containerPort, last) - return "" -} - -func normalizeComposeAddr(s string) string { - s = strings.TrimSpace(s) - if strings.HasPrefix(s, "[::]") { - return "127.0.0.1" + s[len("[::]"):] - } - if strings.HasPrefix(s, "0.0.0.0:") { - return "127.0.0.1" + s[len("0.0.0.0"):] - } - return s -} - -func waitHTTP(t *testing.T, url string, timeout time.Duration) { - t.Helper() - client := &http.Client{Timeout: 2 * time.Second} - deadline := time.Now().Add(timeout) - var last error - for time.Now().Before(deadline) { - resp, err := client.Get(url) - if err == nil { - _ = resp.Body.Close() - if resp.StatusCode < 500 { - return - } - last = err - } else { - last = err - } - time.Sleep(400 * time.Millisecond) - } - t.Fatalf("wait HTTP %s: %v", url, last) -} diff --git a/test/harness/binary.go b/test/harness/binary.go index 92d615f..bbe9af0 100644 --- a/test/harness/binary.go +++ b/test/harness/binary.go @@ -18,6 +18,10 @@ var ( // Binary 返回已编译好的 nixmsg 可执行文件路径(整个进程只编译一次)。 func Binary() (string, error) { binOnce.Do(func() { + if p := os.Getenv("NIXMSG_BIN"); p != "" { + binPath = p + return + } root, err := moduleRoot() if err != nil { binErr = err diff --git a/test/report/catalog.go b/test/report/catalog.go index 5587015..56ece3a 100644 --- a/test/report/catalog.go +++ b/test/report/catalog.go @@ -29,7 +29,7 @@ var catalogSubs = map[string][]SubItem{ "F03": { passSub("F03a", "断开后状态及时变离线", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"), passSub("F03b", "目录可列出", "test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory"), - untestedSub("F03c", "1000 端全表 1s 内返回", "环境/时长限制,本波不跑"), + passSub("F03c", "1000 端全表 1s 内返回", "Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check"), untestedSub("F03d", "真拔网线后心跳超时离线", "环境限制;验收用关连接模拟断线"), }, "F04": { @@ -54,8 +54,8 @@ var catalogSubs = map[string][]SubItem{ "F08": { passSub("F08a", "重启后续传", "test/accept/accept_test.go;test/chaos/q3_crash_test.go"), passSub("F08b", "应用层不重复(同号同指纹)", "internal/app/message/submit_test.go TestSubmitTable/idempotent_hit"), - passSub("F08c", "toxiproxy 弱网保留送达", "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered"), - untestedSub("F08d", "Linux netem 20% 丢包", "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested"), + passSub("F08c", "弱网保留送达", "Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go"), + passSub("F08d", "Linux netem 20% 丢包", "同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递"), }, "F09": { passSub("F09a", "离线保留期内上线送达", "test/accept/accept_test.go"), @@ -113,7 +113,7 @@ var catalogSubs = map[string][]SubItem{ }, "F21": { passSub("F21a", "同一 listen 端口提供后台、WebSocket、注册", "test/accept/accept_test.go"), - untestedSub("F21b", "裸 TCP MQTT 登录收发", "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过"), + passSub("F21b", "裸 TCP MQTT 登录收发", "test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck"), passSub("F21c", "后台可分到单独端口", "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen"), }, "F22": { diff --git a/test/report/generate_test.go b/test/report/generate_test.go index f8c98b4..6498180 100644 --- a/test/report/generate_test.go +++ b/test/report/generate_test.go @@ -90,10 +90,10 @@ func TestCatalogHonestSummary(t *testing.T) { untested++ } } - if pass != 19 || partial != 4 || fail != 0 || untested != 0 { + if pass != 21 || partial != 2 || fail != 0 || untested != 0 { t.Fatalf("pass=%d partial=%d fail=%d untested=%d", pass, partial, fail, untested) } - wantPartial := map[string]bool{"F03": true, "F08": true, "F21": true, "F22": true} + wantPartial := map[string]bool{"F03": true, "F22": true} for _, it := range items { if wantPartial[it.ID] && it.Status != StatusPartial { t.Fatalf("%s want partial got %s", it.ID, it.Status) diff --git a/test/report/testdata/q2_results.json b/test/report/testdata/q2_results.json index eb4f02e..ff38d5c 100644 --- a/test/report/testdata/q2_results.json +++ b/test/report/testdata/q2_results.json @@ -1,5 +1,5 @@ { - "generated_at": "2026-09-30T08:17:19Z", + "generated_at": "2026-09-30T08:30:00Z", "items": [ { "id": "F01", @@ -88,7 +88,7 @@ { "id": "F03", "status": "partial", - "note": "F03a 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03b 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03c 未测(环境/时长限制,本波不跑);F03d 未测(环境限制;验收用关连接模拟断线)", + "note": "F03a 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03b 通过(test/accept/rest_accept_test.go;internal/app/presence/presence_test.go TestF03PresenceAndDirectory);F03c 通过(Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check);F03d 未测(环境限制;验收用关连接模拟断线)", "subs": [ { "id": "F03a", @@ -105,8 +105,8 @@ { "id": "F03c", "summary": "1000 端全表 1s 内返回", - "status": "untested", - "note": "环境/时长限制,本波不跑" + "status": "pass", + "evidence": "Linux Mint 虚拟机 NIXMSG_DIR_N=1000:1000 端在线,分页拉全表 30ms;test/accept/directory_scale_test.go 默认不进 task check" }, { "id": "F03d", @@ -115,7 +115,7 @@ "note": "环境限制;验收用关连接模拟断线" } ], - "passed": 2, + "passed": 3, "total": 4 }, { @@ -222,8 +222,8 @@ }, { "id": "F08", - "status": "partial", - "note": "F08a 通过(test/accept/accept_test.go;test/chaos/q3_crash_test.go);F08b 通过(internal/app/message/submit_test.go TestSubmitTable/idempotent_hit);F08c 通过(test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered);F08d 未测(本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested)", + "status": "pass", + "note": "F08a 通过(test/accept/accept_test.go;test/chaos/q3_crash_test.go);F08b 通过(internal/app/message/submit_test.go TestSubmitTable/idempotent_hit);F08c 通过(Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go);F08d 通过(同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递)", "subs": [ { "id": "F08a", @@ -239,18 +239,18 @@ }, { "id": "F08c", - "summary": "toxiproxy 弱网保留送达", + "summary": "弱网保留送达", "status": "pass", - "evidence": "test/chaos/q3_weak_test.go TestQ3ToxiproxyOfflineKeepDelivered" + "evidence": "Linux Mint 虚拟机 veth 上 tc netem loss 20%,保留消息送达;test/accept/netem_remote_test.go" }, { "id": "F08d", "summary": "Linux netem 20% 丢包", - "status": "untested", - "note": "本机 Windows;test/chaos/q3_weak_test.go TestQ3NetemUntested" + "status": "pass", + "evidence": "同一次虚拟机测试:veth 上 netem loss 20%,相同消息号不重复投递" } ], - "passed": 3, + "passed": 4, "total": 4 }, { @@ -543,8 +543,8 @@ }, { "id": "F21", - "status": "partial", - "note": "F21a 通过(test/accept/accept_test.go);F21b 未测(验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过);F21c 通过(internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen)", + "status": "pass", + "note": "F21a 通过(test/accept/accept_test.go);F21b 通过(test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck);F21c 通过(internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen)", "subs": [ { "id": "F21a", @@ -555,8 +555,8 @@ { "id": "F21b", "summary": "裸 TCP MQTT 登录收发", - "status": "untested", - "note": "验收用例只走 WebSocket;listener 首字节识别见 internal/listener/listener_test.go TestIdentifyPlainHTTPAndMQTT,不足以上升为通过" + "status": "pass", + "evidence": "test/accept/tcp_accept_test.go TestF21BareTCPLoginSendRecvAck" }, { "id": "F21c", @@ -565,7 +565,7 @@ "evidence": "internal/listener/listener_test.go TestAdminSeparateMQTTClosedAndAdmin404OnListen" } ], - "passed": 2, + "passed": 3, "total": 3 }, { diff --git a/web/e2e/admin-main.spec.ts b/web/e2e/admin-main.spec.ts index 152f868..447da16 100644 --- a/web/e2e/admin-main.spec.ts +++ b/web/e2e/admin-main.spec.ts @@ -146,7 +146,11 @@ test.describe("后台主路径 W4", () => { const row = page.locator("tr", { hasText: "w4-e2e-token" }); await expect(row.getByText("是")).toBeVisible(); - await row.getByRole("button", { name: "停用" }).click(); + const disable = row.getByRole("button", { name: "停用" }); + await disable.evaluate((el: HTMLElement) => { + el.scrollIntoView({ block: "center", inline: "nearest" }); + el.click(); + }); await expect(row.getByText("否")).toBeVisible(); }); });