From b75b67928ba88c569ec2640ef12bb7394bf33868 Mon Sep 17 00:00:00 2001 From: Nixevol Date: Wed, 30 Sep 2026 09:20:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=AE=A1=E7=90=86=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E6=8E=A5=E7=9C=9F=E5=AE=9E=E6=8E=A5=E5=8F=A3=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=20Playwright=20e2e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 + Taskfile.yml | 6 +- docs/DEVIATIONS.md | 29 ++- taskfiles/w.yml | 31 +++ web/e2e/admin-main.spec.ts | 151 +++++++++++++ web/e2e/global-teardown.ts | 55 +++++ web/e2e/seedpending/main.go | 327 ++++++++++++++++++++++++++++ web/e2e/start-stack.mjs | 195 +++++++++++++++++ web/eslint.config.js | 14 +- web/package.json | 5 +- web/playwright.config.ts | 27 +++ web/pnpm-lock.yaml | 28 +++ web/src/api/admin-mock.ts | 178 +++++++++++++++ web/src/api/admin.ts | 252 +++++++++++++++++---- web/src/api/mock.ts | 8 +- web/src/api/types.ts | 7 +- web/src/views/EndpointsView.spec.ts | 4 +- web/src/views/GroupsView.vue | 19 +- web/src/views/LoginView.spec.ts | 4 +- web/src/views/LoginView.vue | 3 - web/src/views/RegistrationView.vue | 13 +- web/src/views/TokensView.spec.ts | 4 +- web/src/views/TokensView.vue | 2 +- web/vite.config.ts | 5 +- 24 files changed, 1299 insertions(+), 71 deletions(-) create mode 100644 taskfiles/w.yml create mode 100644 web/e2e/admin-main.spec.ts create mode 100644 web/e2e/global-teardown.ts create mode 100644 web/e2e/seedpending/main.go create mode 100644 web/e2e/start-stack.mjs create mode 100644 web/playwright.config.ts create mode 100644 web/src/api/admin-mock.ts diff --git a/.gitignore b/.gitignore index 9d18d8f..dca0501 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,9 @@ __pycache__/ /test/reports/ /web/test-results/ /web/playwright-report/ +/web/e2e/.bin/ +/web/e2e/.runtime.json +/web/e2e/.server.pid # 编辑器和系统文件 .idea/ diff --git a/Taskfile.yml b/Taskfile.yml index 684f84f..e5d8b7f 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -10,6 +10,9 @@ includes: '*': taskfile: taskfiles/*.yml optional: true + w: + taskfile: taskfiles/w.yml + optional: true tasks: default: @@ -63,9 +66,10 @@ tasks: - task: test itest: - desc: 运行集成测试(T0.5 后可用) + desc: 运行集成测试(含后台网页 Playwright e2e) cmds: - go test ./test/... -count=1 + - task: w:e2e docker: desc: 构建开发用 Docker 镜像 diff --git a/docs/DEVIATIONS.md b/docs/DEVIATIONS.md index 5493f31..dc72abd 100644 --- a/docs/DEVIATIONS.md +++ b/docs/DEVIATIONS.md @@ -701,13 +701,15 @@ - 实际做法:`web/src/api/admin.ts` 统一导出接口函数,内部调用 `mock.ts`;`http.ts` 已实现带 `X-Nixmsg-Request: 1` 的真实请求封装,供 W4 切换。 - 原因:A 线管理接口尚未合入,页面与契约可并行开发。 - 备选:用 MSW 拦截 fetch;当前集中换 `admin.ts` 更简单。 - - 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。 + - 假登录口令:`admin` / `adminpassword`(仅本地 mock,不进后端)。 + - **W4 起已切换**:生产与 e2e 走真实 `/api/admin`;组件 Vitest 通过 `vi.mock("@/api/admin")` → `admin-mock.ts` 仍用假数据。登录页假数据提示已去掉。 2. **列表分页用页码映射 cursor 偏移** - 原条款:admin-api 使用 `cursor`/`limit` 游标分页。 - 实际做法:假数据把 `page` 编成数字偏移 cursor(`String((page-1)*limit)`),`n-data-table` remote 分页照常。 - 原因:Naive UI 表格以页码交互;契约游标对前端透明即可。 - - 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。 + - 备选:W4 若后端 cursor 非偏移编码,在 `admin.ts` 内适配,页面仍用页码。 + - **W4**:后端 cursor 为不透明字符串;首页(空 cursor)主路径 e2e 通过。翻到第 2 页若用数字偏移可能无效,未改页面交互;后续若要稳定翻页,应在 `admin.ts` 缓存服务端 `next_cursor`。 3. **帮助图标不用独立图标库** - 原条款:DEVELOPMENT 2.4 用 `n-tooltip`;协作规则要求只用 Naive UI。 @@ -727,7 +729,28 @@ - 原因:与重置密码、解锁同级的行内动作更贴桌面工作流。 - 备选:无。 -## SDK 一 S1 +### W4 2026-09-30 + +1. **接真实接口并适配 A 线字段** + - 原条款:TASKS W4;admin-api 令牌 `id` 示例为数字。 + - 实际做法:`admin.ts` 全部改为 `requestAdmin`;令牌 `id` 类型改为 `string`(对齐 A1 TEXT);`talk-password` 用 `PUT`;概览可选 `endpoints_self`;建群省略空 `id`(A3 不接受自定义 id)。不改服务器。 + - 原因:与已合并 A 线实现一致。 + - 备选:无。 + - 影响:组件测用 `admin-mock.ts`;页面逻辑不变。 + +2. **未完成投递由 e2e 用 MQTT seed 造出** + - 原条款:DEVELOPMENT 第 13 节后台主路径「看到未完成记录」;管理 messages 只读。 + - 实际做法:Playwright 流程里用管理接口开通第二端后,运行 `web/e2e/seedpending`(MQTT hello + 未来 `send_at_ms` 的 send),再打开投递记录页断言 `scheduled`。测完杀进程并删临时目录。 + - 原因:管理 API 不能写消息;不改服务器业务代码。 + - 备选:插入 SQLite(并发/锁风险);或依赖已有记录(空库无)。 + - 影响:e2e 依赖本机 `go` 与 Chromium。 + +3. **Playwright 挂入 `task itest` / `task w:e2e`** + - 原条款:TASKS W4「端到端测试在 task itest 里通过」;Taskfile 由总控维护、各线写 `taskfiles/<线>.yml`。 + - 实际做法:新增 `taskfiles/w.yml`;主 `Taskfile.yml` 显式 `includes.w`(本机 Task 3.53 下 `taskfiles/*.yml` glob 未挂上各线任务)并把 `itest` 在 `go test ./test/...` 后追加 `task: w:e2e`。Playwright 的 `webServer` 先于 `globalSetup` 启动,故用 `e2e/start-stack.mjs` 同时起临时 nixmsg 与 Vite;代理目标 `NIXMSG_PROXY_TARGET`(开发默认仍 7443)。浏览器用 `pnpm exec playwright install chromium`,不改全局配置。 + - 原因:满足 W4 验收且隔离安装。 + - 备选:仅文档要求手跑 e2e;未采用。 + - 影响:`task itest` 变长;需本机 Go/Node/Chromium。 ### S1.1 传输层可注入假实现(Go / JS) diff --git a/taskfiles/w.yml b/taskfiles/w.yml new file mode 100644 index 0000000..3253f3c --- /dev/null +++ b/taskfiles/w.yml @@ -0,0 +1,31 @@ +version: "3" + +tasks: + e2e: + desc: Playwright 后台主路径端到端(起临时 nixmsg + Vite 代理) + dir: web + cmds: + - pnpm install + - pnpm exec playwright install chromium + - pnpm run test:e2e + + typecheck: + desc: 前端 vue-tsc + dir: web + cmds: + - pnpm install + - pnpm run typecheck + + lint: + desc: 前端 ESLint + dir: web + cmds: + - pnpm install + - pnpm run lint + + unit: + desc: 前端 Vitest + dir: web + cmds: + - pnpm install + - pnpm run test diff --git a/web/e2e/admin-main.spec.ts b/web/e2e/admin-main.spec.ts new file mode 100644 index 0000000..6ce66f9 --- /dev/null +++ b/web/e2e/admin-main.spec.ts @@ -0,0 +1,151 @@ +import { expect, test, type APIRequestContext, type Page } from "@playwright/test"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +type RuntimeInfo = { + httpBase: string; + adminPassword: string; + dataDir: string; + binPath: string; + vitePort: number; +}; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(webRoot, ".."); +const runtimePath = path.join(webRoot, "e2e", ".runtime.json"); + +async function loadRuntime(): Promise { + return JSON.parse(await readFile(runtimePath, "utf8")) as RuntimeInfo; +} + +async function login(page: Page, password: string) { + await page.goto("/login"); + await page.getByPlaceholder("admin").fill("admin"); + await page.getByPlaceholder("请输入密码").fill(password); + await page.getByTestId("login-submit").click(); + await expect(page).toHaveURL(/\/overview/); +} + +function nav(page: Page, label: string) { + return page.locator(".n-menu").getByText(label, { exact: true }).click(); +} + +async function createPeerViaAdmin( + request: APIRequestContext, + httpBase: string, + password: string, + id: string, + loginPassword: string, +) { + const loginRes = await request.post(`${httpBase}/api/admin/login`, { + data: { username: "admin", password }, + }); + expect(loginRes.ok()).toBeTruthy(); + const res = await request.post(`${httpBase}/api/admin/endpoints`, { + headers: { "X-Nixmsg-Request": "1" }, + data: { id, name: id, login_password: loginPassword }, + }); + const body = await res.json(); + expect(res.ok(), JSON.stringify(body)).toBeTruthy(); + expect(body.ok).toBe(true); +} + +function seedPending(httpBase: string, from: string, fromPass: string, to: string): Promise { + return new Promise((resolve, reject) => { + const child = spawn( + "go", + [ + "run", + "./web/e2e/seedpending", + "-base", + httpBase, + "-from", + from, + "-from-pass", + fromPass, + "-to", + to, + ], + { cwd: repoRoot, shell: process.platform === "win32" }, + ); + let out = ""; + child.stdout.on("data", (d) => { + out += String(d); + }); + child.stderr.on("data", (d) => { + out += String(d); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`seedpending failed: ${out}`)); + }); + }); +} + +test.describe("后台主路径 W4", () => { + test("登录、开通、注册、建群、未完成投递、令牌创建停用", async ({ page, request }) => { + const rt = await loadRuntime(); + await login(page, rt.adminPassword); + + await nav(page, "端"); + await expect(page).toHaveURL(/\/endpoints/); + await page.getByRole("button", { name: "开通", exact: true }).click(); + await page.getByTestId("create-id").locator("input").fill("w4ep001"); + await page.getByTestId("create-name").locator("input").fill("W4端一"); + await page.getByTestId("create-submit").click(); + await expect(page.getByText("只显示一次")).toBeVisible(); + await page.getByRole("button", { name: "我已保存" }).click(); + await expect(page.getByText("只显示一次")).toHaveCount(0); + await expect(page.getByText("w4ep001")).toBeVisible(); + + // 管理接口开通第二端(已知密码),供 MQTT 造未完成投递;管理 messages 只读 + await createPeerViaAdmin(request, rt.httpBase, rt.adminPassword, "w4ep002", "password12ab"); + + await nav(page, "注册设置"); + await expect(page).toHaveURL(/\/registration/); + await page.getByTestId("reg-enabled").click(); + await expect(page.getByText("已开启自助注册")).toBeVisible(); + await page.getByTestId("reg-code").locator("input").fill("w4-reg-code-01"); + await page.getByTestId("reg-save-code").click(); + await expect(page.getByText("安全码已更新")).toBeVisible(); + + await nav(page, "群"); + await expect(page).toHaveURL(/\/groups/); + await page.getByTestId("group-create-open").click(); + await page.getByTestId("group-name").locator("input").fill("W4测试群"); + await page.getByTestId("group-owner").locator("input").fill("w4ep001"); + await page.getByTestId("group-members").locator("textarea").fill("w4ep002"); + await page.getByTestId("group-create-submit").click(); + await expect(page.getByText("W4测试群")).toBeVisible(); + + await seedPending(rt.httpBase, "w4ep002", "password12ab", "w4ep001"); + + await nav(page, "投递记录"); + await expect(page).toHaveURL(/\/messages/); + await expect(page.getByText("w4-e2e-scheduled")).toBeVisible(); + await expect(page.getByRole("cell", { name: "scheduled", exact: true })).toBeVisible(); + + await nav(page, "API 令牌"); + await expect(page).toHaveURL(/\/tokens/); + await page.getByTestId("token-create-open").click(); + await page.getByTestId("token-name").locator("input").fill("w4-e2e-token"); + await page.getByTestId("token-create-submit").click(); + await expect(page.getByText("令牌只显示一次")).toBeVisible(); + const bodyText = await page.locator("body").innerText(); + const tokenMatch = bodyText.match(/nxm_[A-Za-z0-9_-]+/); + expect(tokenMatch).toBeTruthy(); + const shown = tokenMatch![0]; + await page.getByRole("button", { name: "我已保存" }).click(); + await expect(page.getByText("令牌只显示一次")).toHaveCount(0); + await expect(page.getByText(shown)).toHaveCount(0); + await expect(page.getByText("w4-e2e-token")).toBeVisible(); + + const row = page.locator("tr", { hasText: "w4-e2e-token" }); + await expect(row.getByText("是")).toBeVisible(); + await row.getByRole("button", { name: "停用" }).click(); + await expect(row.getByText("否")).toBeVisible(); + }); +}); diff --git a/web/e2e/global-teardown.ts b/web/e2e/global-teardown.ts new file mode 100644 index 0000000..f324eb8 --- /dev/null +++ b/web/e2e/global-teardown.ts @@ -0,0 +1,55 @@ +import { readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const runtimePath = path.join(webRoot, "e2e", ".runtime.json"); +const pidPath = path.join(webRoot, "e2e", ".server.pid"); +const binDir = path.join(webRoot, "e2e", ".bin"); + +async function killPid(pid: number) { + if (!pid) return; + if (process.platform === "win32") { + await new Promise((resolve) => { + const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { shell: true }); + child.on("close", () => resolve()); + child.on("error", () => resolve()); + }); + return; + } + try { + process.kill(pid, "SIGKILL"); + } catch { + /* ignore */ + } +} + +export default async function globalTeardown() { + let dataDir = ""; + let servePid = 0; + try { + const info = JSON.parse(await readFile(runtimePath, "utf8")) as { + dataDir?: string; + servePid?: number; + }; + dataDir = info.dataDir ?? ""; + servePid = info.servePid ?? 0; + } catch { + /* ignore */ + } + try { + const pidRaw = (await readFile(pidPath, "utf8")).trim(); + const pid = Number(pidRaw); + if (Number.isFinite(pid) && pid > 0) servePid = pid; + } catch { + /* ignore */ + } + await killPid(servePid); + if (dataDir) { + await rm(dataDir, { recursive: true, force: true }); + } + await rm(runtimePath, { force: true }); + await rm(pidPath, { force: true }); + await rm(binDir, { recursive: true, force: true }); +} diff --git a/web/e2e/seedpending/main.go b/web/e2e/seedpending/main.go new file mode 100644 index 0000000..7854929 --- /dev/null +++ b/web/e2e/seedpending/main.go @@ -0,0 +1,327 @@ +// 用 MQTT 向真实 nixmsg 提交一条未来定时消息,供后台「投递记录」页看到未完成记录。 +// 管理接口只读 messages,不能造记录;不改服务器业务代码。 +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "os" + "sync" + "time" + + "git.asio.asia/nixevol/NixMsg/internal/protocol" + "git.asio.asia/nixevol/NixMsg/test/harness" + "github.com/mochi-mqtt/server/v2/packets" +) + +func main() { + base := flag.String("base", "", "http base of nixmsg, e.g. http://127.0.0.1:12345") + fromID := flag.String("from", "", "sender endpoint id") + fromPass := flag.String("from-pass", "", "sender login password") + toID := flag.String("to", "", "receiver endpoint id") + flag.Parse() + if *base == "" || *fromID == "" || *fromPass == "" || *toID == "" { + fmt.Fprintln(os.Stderr, "usage: seedpending -base URL -from ID -from-pass PWD -to ID") + os.Exit(2) + } + if err := seed(*base, *fromID, *fromPass, *toID); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + fmt.Println("ok") +} + +func seed(httpBase, fromID, fromPass, toID string) error { + mc, err := harness.DialMQTTWebSocket(httpBase, 10*time.Second) + if err != nil { + return fmt.Errorf("dial mqtt: %w", err) + } + defer func() { _ = mc.Close() }() + + s := &session{mc: mc, endpointID: fromID, pktID: 10, done: make(chan struct{})} + if helloErr := s.connectSubscribeHello(fromPass); helloErr != nil { + return helloErr + } + go s.readLoop() + defer s.close() + + sendAt := time.Now().Add(2 * time.Hour).UnixMilli() + frame := map[string]any{ + "v": 1, "type": "send", "rid": "w4seed1", "id": "w4-e2e-scheduled", + "to": map[string]any{"kind": "endpoint", "id": toID}, + "body": map[string]any{"enc": "utf8", "data": "w4-e2e-pending"}, + "send_at_ms": sendAt, + } + resp, reqErr := s.request(frame, 15*time.Second) + if reqErr != nil { + return reqErr + } + if !resp.ok { + return fmt.Errorf("send failed: %+v", resp.raw) + } + return nil +} + +type session struct { + mc harness.MQTTClient + endpointID string + pktID uint16 + mu sync.Mutex + inbox []map[string]any + closed bool + done chan struct{} +} + +type appResp struct { + ok bool + raw map[string]any +} + +func (s *session) close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + s.mu.Unlock() + _ = s.mc.Close() + select { + case <-s.done: + case <-time.After(3 * time.Second): + } +} + +func (s *session) nextPkt() uint16 { + s.pktID++ + if s.pktID == 0 { + s.pktID = 1 + } + return s.pktID +} + +func (s *session) connectSubscribeHello(password string) error { + pk := packets.Packet{ + FixedHeader: packets.FixedHeader{Type: packets.Connect}, + ProtocolVersion: 5, + Connect: packets.ConnectParams{ + ProtocolName: []byte("MQTT"), + Clean: true, + ClientIdentifier: s.endpointID, + Keepalive: 30, + UsernameFlag: true, + Username: []byte(s.endpointID), + PasswordFlag: true, + Password: []byte(password), + }, + } + var buf bytes.Buffer + if err := pk.ConnectEncode(&buf); err != nil { + return err + } + if err := s.mc.Send(buf.Bytes()); err != nil { + return err + } + ack, err := s.mc.Recv() + if err != nil { + return err + } + if len(ack) < 4 || ack[0]>>4 != packets.Connack || ack[3] != 0 { + return fmt.Errorf("connack %x", ack) + } + + sub := packets.Packet{ + FixedHeader: packets.FixedHeader{Type: packets.Subscribe, Qos: 1}, + ProtocolVersion: 5, + PacketID: s.nextPkt(), + Filters: packets.Subscriptions{ + {Filter: "nix/c/" + s.endpointID + "/down", Qos: 1}, + }, + } + buf.Reset() + if err := sub.SubscribeEncode(&buf); err != nil { + return err + } + if err := s.mc.Send(buf.Bytes()); err != nil { + return err + } + if _, err := s.mc.Recv(); err != nil { + return err + } + + hello, _ := protocol.Marshal(protocol.Hello{V: protocol.Version, Type: protocol.TypeHello, RID: "h0"}) + if err := s.publishRaw(hello); err != nil { + return err + } + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + raw, err := s.mc.Recv() + if err != nil { + return err + } + m := s.handlePacket(raw) + if m == nil { + continue + } + if m["type"] == "resp" && m["ok"] == true { + return nil + } + if m["type"] == "resp" { + return fmt.Errorf("hello failed: %v", m) + } + s.push(m) + } + return fmt.Errorf("hello timeout") +} + +func (s *session) publishRaw(payload []byte) error { + pub := packets.Packet{ + FixedHeader: packets.FixedHeader{Type: packets.Publish, Qos: 1}, + ProtocolVersion: 5, + TopicName: "nix/c/" + s.endpointID + "/up", + PacketID: s.nextPkt(), + Payload: payload, + } + var buf bytes.Buffer + if err := pub.PublishEncode(&buf); err != nil { + return err + } + return s.mc.Send(buf.Bytes()) +} + +func (s *session) readLoop() { + defer close(s.done) + for { + raw, err := s.mc.Recv() + if err != nil { + return + } + m := s.handlePacket(raw) + if m != nil { + s.push(m) + } + } +} + +func (s *session) handlePacket(raw []byte) map[string]any { + if len(raw) < 2 { + return nil + } + typ := raw[0] >> 4 + qos := (raw[0] >> 1) & 0x3 + switch typ { + case packets.Puback, packets.Pingresp, packets.Suback: + return nil + case packets.Publish: + payload, err := decodePublishPayload(raw) + if err != nil { + return nil + } + if qos == 1 { + rem, n, _ := decodeRemainingLength(raw[1:]) + body := raw[1+n:] + pk := packets.Packet{ProtocolVersion: 5, FixedHeader: packets.FixedHeader{Type: packets.Publish, Remaining: rem, Qos: qos}} + if decErr := pk.PublishDecode(body); decErr == nil && pk.PacketID != 0 { + ack := packets.Packet{ + FixedHeader: packets.FixedHeader{Type: packets.Puback}, + ProtocolVersion: 5, + PacketID: pk.PacketID, + } + var buf bytes.Buffer + if encErr := ack.PubackEncode(&buf); encErr == nil { + _ = s.mc.Send(buf.Bytes()) + } + } + } + var m map[string]any + if json.Unmarshal(payload, &m) != nil { + return nil + } + return m + default: + return nil + } +} + +func (s *session) push(m map[string]any) { + s.mu.Lock() + s.inbox = append(s.inbox, m) + s.mu.Unlock() +} + +func (s *session) request(frame map[string]any, timeout time.Duration) (appResp, error) { + rid, _ := frame["rid"].(string) + payload, err := protocol.Marshal(frame) + if err != nil { + return appResp{}, err + } + if err := s.publishRaw(payload); err != nil { + return appResp{}, err + } + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + m := s.takeMatching(func(x map[string]any) bool { + return x["type"] == "resp" && x["rid"] == rid + }) + if m != nil { + return appResp{ok: m["ok"] == true, raw: m}, nil + } + time.Sleep(5 * time.Millisecond) + } + return appResp{}, fmt.Errorf("timeout waiting resp rid=%s", rid) +} + +func (s *session) takeMatching(pred func(map[string]any) bool) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + for i, m := range s.inbox { + if pred(m) { + s.inbox = append(s.inbox[:i], s.inbox[i+1:]...) + return m + } + } + return nil +} + +func decodePublishPayload(raw []byte) ([]byte, error) { + if len(raw) < 2 { + return nil, io.ErrUnexpectedEOF + } + rem, n, err := decodeRemainingLength(raw[1:]) + if err != nil { + return nil, err + } + body := raw[1+n:] + if len(body) != rem { + return nil, io.ErrUnexpectedEOF + } + pk := packets.Packet{ + ProtocolVersion: 5, + FixedHeader: packets.FixedHeader{ + Type: packets.Publish, + Remaining: rem, + Qos: (raw[0] >> 1) & 0x3, + }, + } + if err := pk.PublishDecode(body); err != nil { + return nil, err + } + return pk.Payload, nil +} + +func decodeRemainingLength(b []byte) (value int, n int, err error) { + var mul uint32 = 1 + var v uint32 + for i := 0; i < len(b) && i < 4; i++ { + v += uint32(b[i]&127) * mul + n++ + if b[i]&128 == 0 { + return int(v), n, nil + } + mul *= 128 + } + return 0, 0, io.ErrUnexpectedEOF +} diff --git a/web/e2e/start-stack.mjs b/web/e2e/start-stack.mjs new file mode 100644 index 0000000..f71e186 --- /dev/null +++ b/web/e2e/start-stack.mjs @@ -0,0 +1,195 @@ +/** + * Playwright webServer 入口:先起临时 nixmsg,再起 Vite(代理到该进程)。 + * 注意:Playwright 会在 globalSetup 之前启动 webServer。 + */ +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const webRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = path.resolve(webRoot, ".."); +const runtimePath = path.join(webRoot, "e2e", ".runtime.json"); +const pidPath = path.join(webRoot, "e2e", ".server.pid"); +const binDir = path.join(webRoot, "e2e", ".bin"); + +function run(cwd, cmd, args, extraEnv) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + cwd, + shell: process.platform === "win32", + env: { ...process.env, ...extraEnv }, + }); + let out = ""; + let err = ""; + child.stdout?.on("data", (d) => { + out += String(d); + }); + child.stderr?.on("data", (d) => { + err += String(d); + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolve(out + err); + else reject(new Error(`${cmd} ${args.join(" ")} failed (${code}):\n${out}\n${err}`)); + }); + }); +} + +function parseAdminPassword(text) { + const lines = text.split(/\r?\n/); + for (const raw of lines) { + const line = raw.trim(); + const lower = line.toLowerCase(); + if (lower.startsWith("password:")) return line.slice("password:".length).trim(); + if (lower.startsWith("admin password:")) return line.slice("admin password:".length).trim(); + } + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (line.length >= 12 && !line.toLowerCase().includes("error") && !/\s/.test(line)) { + return line; + } + } + return ""; +} + +async function waitAddr(file, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let last = ""; + while (Date.now() < deadline) { + try { + const addr = (await readFile(file, "utf8")).trim(); + if (addr) return addr; + last = "empty"; + } catch (e) { + last = e instanceof Error ? e.message : String(e); + } + await delay(50); + } + throw new Error(`wait listen.addr timeout: ${last}`); +} + +async function killPid(pid) { + if (!pid) return; + if (process.platform === "win32") { + await new Promise((resolve) => { + const child = spawn("taskkill", ["/PID", String(pid), "/T", "/F"], { shell: true }); + child.on("close", () => resolve()); + child.on("error", () => resolve()); + }); + return; + } + try { + process.kill(pid, "SIGKILL"); + } catch { + /* ignore */ + } +} + +async function cleanup(dataDir, servePid) { + await killPid(servePid); + if (dataDir) await rm(dataDir, { recursive: true, force: true }); + await rm(runtimePath, { force: true }); + await rm(pidPath, { force: true }); + await rm(binDir, { recursive: true, force: true }); +} + +async function main() { + await mkdir(binDir, { recursive: true }); + const bin = path.join(binDir, process.platform === "win32" ? "nixmsg.exe" : "nixmsg"); + await run(repoRoot, "go", ["build", "-o", bin, "./cmd/nixmsg"]); + + const dataDir = await mkdtemp(path.join(tmpdir(), "nixmsg-w4-e2e-")); + const cfgPath = path.join(dataDir, "config.yaml"); + const yaml = `listen: "127.0.0.1:0"\ndata_dir: ${JSON.stringify(dataDir.replace(/\\/g, "/"))}\n`; + await writeFile(cfgPath, yaml, "utf8"); + + const initOut = await run(repoRoot, bin, ["admin", "init"], { NIXMSG_CONFIG: cfgPath }); + const adminPassword = parseAdminPassword(initOut); + if (!adminPassword) { + await rm(dataDir, { recursive: true, force: true }); + throw new Error(`admin init password not found:\n${initOut}`); + } + + const serve = spawn(bin, ["serve"], { + env: { ...process.env, NIXMSG_CONFIG: cfgPath }, + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + }); + let serveLog = ""; + serve.stdout.on("data", (d) => { + serveLog += String(d); + }); + serve.stderr.on("data", (d) => { + serveLog += String(d); + }); + + let addr; + try { + addr = await waitAddr(path.join(dataDir, "listen.addr"), 15_000); + } catch (e) { + await killPid(serve.pid); + await rm(dataDir, { recursive: true, force: true }); + throw new Error(`${e}\nserve log:\n${serveLog}`); + } + + const vitePort = 5179; + const info = { + httpBase: `http://${addr}`, + adminPassword, + dataDir, + binPath: bin, + vitePort, + servePid: serve.pid, + }; + await writeFile(runtimePath, JSON.stringify(info, null, 2), "utf8"); + await writeFile(pidPath, String(serve.pid ?? ""), "utf8"); + + const vite = spawn( + process.platform === "win32" ? "pnpm.cmd" : "pnpm", + ["exec", "vite", "--host", "127.0.0.1", "--port", String(vitePort), "--strictPort"], + { + cwd: webRoot, + env: { ...process.env, NIXMSG_PROXY_TARGET: info.httpBase }, + stdio: "inherit", + shell: process.platform === "win32", + }, + ); + + const shutdown = async () => { + vite.kill(); + await cleanup(dataDir, serve.pid); + process.exit(0); + }; + process.on("SIGINT", () => { + void shutdown(); + }); + process.on("SIGTERM", () => { + void shutdown(); + }); + process.on("exit", () => { + try { + if (serve.pid) { + if (process.platform === "win32") { + spawn("taskkill", ["/PID", String(serve.pid), "/T", "/F"], { shell: true, detached: true, stdio: "ignore" }); + } else { + process.kill(serve.pid, "SIGKILL"); + } + } + } catch { + /* ignore */ + } + }); + + vite.on("exit", async (code) => { + await cleanup(dataDir, serve.pid); + process.exit(code ?? 1); + }); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/web/eslint.config.js b/web/eslint.config.js index 461c448..691384a 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -5,7 +5,19 @@ import globals from "globals"; import eslintConfigPrettier from "eslint-config-prettier"; export default tseslint.config( - { ignores: ["dist/**", "node_modules/**", "stub/**"] }, + { + ignores: [ + "dist/**", + "node_modules/**", + "stub/**", + "playwright-report/**", + "test-results/**", + "e2e/.bin/**", + "e2e/.runtime.json", + "e2e/.server.pid", + "e2e/start-stack.mjs", + ], + }, js.configs.recommended, ...tseslint.configs.recommended, ...pluginVue.configs["flat/recommended"], diff --git a/web/package.json b/web/package.json index e8c6000..13d02ef 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,9 @@ "typecheck": "vue-tsc -b --noEmit", "lint": "eslint .", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:e2e": "playwright test", + "test:e2e:install": "playwright install chromium" }, "dependencies": { "naive-ui": "^2.43.1", @@ -20,6 +22,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@playwright/test": "^1.55.1", "@types/node": "^26.6.3", "@vitejs/plugin-vue": "^6.0.1", "@vue/test-utils": "^2.5.1", diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..3b8516c --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from "@playwright/test"; + +const vitePort = 5179; + +export default defineConfig({ + testDir: "./e2e", + testMatch: "**/*.spec.ts", + timeout: 120_000, + expect: { timeout: 15_000 }, + fullyParallel: false, + workers: 1, + retries: 0, + reporter: [["list"]], + // webServer 在 globalSetup 之前启动,故在 start-stack 内起 nixmsg + Vite + globalTeardown: "./e2e/global-teardown.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: `http://127.0.0.1:${vitePort}`, + trace: "on-first-retry", + }, + webServer: { + command: "node e2e/start-stack.mjs", + url: `http://127.0.0.1:${vitePort}`, + reuseExistingServer: false, + timeout: 180_000, + }, +}); diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 4914935..9444199 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.11.0) + '@playwright/test': + specifier: ^1.55.1 + version: 1.63.0 '@types/node': specifier: ^26.6.3 version: 26.6.3 @@ -351,6 +354,11 @@ packages: '@one-ini/wasm@0.2.1': resolution: {integrity: sha512-TUqERXGNTifZ9y2g3wPxQrw3HpHv/02DsW3D90T9x0hhonrL1ZqpSmNrU2XkoIq0fP1N6gZfVQzy2Fw1ZvGBNg==} + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} @@ -1072,6 +1080,16 @@ packages: typescript: optional: true + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + postcss-selector-parser@7.1.6: resolution: {integrity: sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==} engines: {node: '>=4'} @@ -1534,6 +1552,10 @@ snapshots: '@one-ini/wasm@0.2.1': {} + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + '@rolldown/pluginutils@1.0.1': {} '@rollup/rollup-android-arm-eabi@4.63.5': @@ -2282,6 +2304,12 @@ snapshots: optionalDependencies: typescript: 5.9.3 + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + postcss-selector-parser@7.1.6: dependencies: cssesc: 3.0.0 diff --git a/web/src/api/admin-mock.ts b/web/src/api/admin-mock.ts new file mode 100644 index 0000000..53a1f60 --- /dev/null +++ b/web/src/api/admin-mock.ts @@ -0,0 +1,178 @@ +/** + * Vitest 组件测用:把 `@/api/admin` 指到假数据(不连真实后端)。 + * 生产与 Playwright e2e 走真实 `admin.ts`。 + */ +import { ApiError } from "./http"; +import { mockApi } from "./mock"; +import { message } from "@/utils/notify"; +import type { + EndpointCreateRequest, + EndpointListQuery, + EndpointPatchRequest, + GroupFailed, + MeInfo, + Overview, + RegistrationUpdateRequest, +} from "./types"; + +export { ApiError }; +export type * from "./types"; + +async function run(fn: () => Promise, silent = false): Promise { + try { + return await fn(); + } catch (e) { + if (!silent && e instanceof ApiError) { + message.error(e.message); + } else if (!silent && e instanceof Error) { + message.error(e.message); + } + throw e; + } +} + +export function login(username: string, password: string) { + return run(() => mockApi.login(username, password), true); +} + +export function logout() { + return run(() => mockApi.logout()); +} + +export function fetchMe(silent = false) { + return run(() => mockApi.me(), silent); +} + +export function changePassword(oldPassword: string, newPassword: string) { + return run(() => mockApi.changePassword(oldPassword, newPassword)); +} + +export function fetchOverview(): Promise { + return run(() => mockApi.overview()); +} + +export function listEndpoints(q?: EndpointListQuery) { + return run(() => mockApi.listEndpoints(q)); +} + +export function createEndpoint(body: EndpointCreateRequest) { + return run(() => mockApi.createEndpoint(body)); +} + +export function importEndpoints(csvText: string) { + return run(() => mockApi.importEndpoints(csvText), true); +} + +export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete") { + return run(() => mockApi.batchEndpoints(ids, action)); +} + +export function getEndpoint(id: string) { + return run(() => mockApi.getEndpoint(id)); +} + +export function patchEndpoint(id: string, body: EndpointPatchRequest) { + return run(() => mockApi.patchEndpoint(id, body)); +} + +export function deleteEndpoint(id: string) { + return run(() => mockApi.deleteEndpoint(id)); +} + +export function kickEndpoint(id: string) { + return run(() => mockApi.kickEndpoint(id)); +} + +export function resetLoginPassword(id: string, loginPassword?: string) { + return run(() => mockApi.resetLoginPassword(id, loginPassword)); +} + +export function setTalkPassword(id: string, talkPassword: string) { + return run(() => mockApi.setTalkPassword(id, talkPassword)); +} + +export function unlockEndpoint(id: string) { + return run(() => mockApi.unlockEndpoint(id)); +} + +export function getRegistration() { + return run(() => mockApi.getRegistration()); +} + +export function updateRegistration(body: RegistrationUpdateRequest) { + return run(() => mockApi.updateRegistration(body)); +} + +export function listTokens() { + return run(() => mockApi.listTokens()); +} + +export function createToken(name: string) { + return run(() => mockApi.createToken(name)); +} + +export function patchToken(id: string, body: { name?: string; enabled?: boolean }) { + return run(() => mockApi.patchToken(id, body)); +} + +export function deleteToken(id: string) { + return run(() => mockApi.deleteToken(id)); +} + +export function listGroups(query?: string, cursor?: string, limit?: number) { + return run(() => mockApi.listGroups(query, cursor, limit)); +} + +export function createGroup(body: { + id?: string; + name: string; + owner_id: string; + member_ids?: string[]; +}): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> { + return run(() => mockApi.createGroup(body)); +} + +export function getGroup(id: string, cursor?: string, limit?: number) { + return run(() => mockApi.getGroup(id, cursor, limit)); +} + +export function renameGroup(id: string, name: string) { + return run(() => mockApi.renameGroup(id, name)); +} + +export function deleteGroup(id: string) { + return run(() => mockApi.deleteGroup(id)); +} + +export function addGroupMembers(id: string, memberIds: string[]) { + return run(() => mockApi.addGroupMembers(id, memberIds)); +} + +export function removeGroupMember(id: string, endpointId: string) { + return run(() => mockApi.removeGroupMember(id, endpointId)); +} + +export function transferGroup(id: string, endpointId: string) { + return run(() => mockApi.transferGroup(id, endpointId)); +} + +export function listMessages(q: { + cursor?: string; + limit?: number; + sender_id?: string; + endpoint_id?: string; + group_id?: string; + state?: string; +}) { + return run(() => mockApi.listMessages(q)); +} + +export function getMessage(seq: number) { + return run(() => mockApi.getMessage(seq)); +} + +export function getSettings() { + return run(() => mockApi.getSettings()); +} + +export type { MeInfo }; diff --git a/web/src/api/admin.ts b/web/src/api/admin.ts index faa973e..7944f69 100644 --- a/web/src/api/admin.ts +++ b/web/src/api/admin.ts @@ -1,8 +1,7 @@ /** - * 管理接口函数集中处。当前走假数据(mock);W4 改为 requestAdmin 即可,页面不用改。 + * 管理接口:真实请求 `/api/admin`(开发时由 Vite 代理到本机 nixmsg)。 */ -import { ApiError } from "./http"; -import { mockApi } from "./mock"; +import { ApiError, requestAdmin } from "./http"; import { message } from "@/utils/notify"; import type { ApiToken, @@ -43,97 +42,212 @@ async function run(fn: () => Promise, silent = false): Promise { } } +function buildQuery(params: Record): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + sp.set(k, String(v)); + } + const q = sp.toString(); + return q ? `?${q}` : ""; +} + export function login(username: string, password: string) { - // 登录页自行展示错误,避免与 message 重复 - return run(() => mockApi.login(username, password), true); + return run( + () => + requestAdmin<{ username: string }>("/api/admin/login", { + method: "POST", + body: { username, password }, + silent: true, + }), + true, + ); } export function logout() { - return run(() => mockApi.logout()); + return run(() => requestAdmin>("/api/admin/logout", { method: "POST" })); } export function fetchMe(silent = false) { - return run(() => mockApi.me(), silent); + return run(() => requestAdmin("/api/admin/me", { silent }), silent); } export function changePassword(oldPassword: string, newPassword: string) { - return run(() => mockApi.changePassword(oldPassword, newPassword)); + return run(() => + requestAdmin>("/api/admin/password", { + method: "POST", + body: { old_password: oldPassword, new_password: newPassword }, + }), + ); } export function fetchOverview(): Promise { - return run(() => mockApi.overview()); + return run(() => requestAdmin("/api/admin/overview")); } -export function listEndpoints(q?: EndpointListQuery): Promise> { - return run(() => mockApi.listEndpoints(q)); +export function listEndpoints(q: EndpointListQuery = {}): Promise> { + return run(() => + requestAdmin>( + `/api/admin/endpoints${buildQuery({ + cursor: q.cursor, + limit: q.limit, + source: q.source || undefined, + online: q.online === "" || q.online === undefined ? undefined : q.online, + enabled: q.enabled === "" || q.enabled === undefined ? undefined : q.enabled, + query: q.query, + })}`, + ), + ); } export function createEndpoint(body: EndpointCreateRequest): Promise { - return run(() => mockApi.createEndpoint(body)); + return run(() => + requestAdmin("/api/admin/endpoints", { method: "POST", body }), + ); } export function importEndpoints(csvText: string): Promise<{ items: ImportItem[] }> { - return run(() => mockApi.importEndpoints(csvText), true); + return run( + () => + requestAdmin<{ items: ImportItem[] }>("/api/admin/endpoints/import", { + method: "POST", + rawBody: csvText, + contentType: "text/csv", + silent: true, + }), + true, + ); } -export function batchEndpoints(ids: string[], action: "disable" | "enable" | "delete"): Promise { - return run(() => mockApi.batchEndpoints(ids, action)); +export function batchEndpoints( + ids: string[], + action: "disable" | "enable" | "delete", +): Promise { + return run(() => + requestAdmin("/api/admin/endpoints/batch", { + method: "POST", + body: { ids, action }, + }), + ); } export function getEndpoint(id: string): Promise { - return run(() => mockApi.getEndpoint(id)); + return run(() => requestAdmin(`/api/admin/endpoints/${encodeURIComponent(id)}`)); } export function patchEndpoint(id: string, body: EndpointPatchRequest): Promise { - return run(() => mockApi.patchEndpoint(id, body)); + return run(() => + requestAdmin(`/api/admin/endpoints/${encodeURIComponent(id)}`, { + method: "PATCH", + body, + }), + ); } export function deleteEndpoint(id: string) { - return run(() => mockApi.deleteEndpoint(id)); + return run(() => + requestAdmin>(`/api/admin/endpoints/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + ); } export function kickEndpoint(id: string) { - return run(() => mockApi.kickEndpoint(id)); + return run(() => + requestAdmin<{ kicked: boolean }>(`/api/admin/endpoints/${encodeURIComponent(id)}/kick`, { + method: "POST", + }), + ); } export function resetLoginPassword(id: string, loginPassword?: string) { - return run(() => mockApi.resetLoginPassword(id, loginPassword)); + return run(() => + requestAdmin<{ login_password: string }>( + `/api/admin/endpoints/${encodeURIComponent(id)}/reset-login-password`, + { + method: "POST", + body: loginPassword ? { login_password: loginPassword } : {}, + }, + ), + ); } export function setTalkPassword(id: string, talkPassword: string) { - return run(() => mockApi.setTalkPassword(id, talkPassword)); + return run(() => + requestAdmin<{ talk_password_set: boolean }>( + `/api/admin/endpoints/${encodeURIComponent(id)}/talk-password`, + { + method: "PUT", + body: { talk_password: talkPassword }, + }, + ), + ); } export function unlockEndpoint(id: string) { - return run(() => mockApi.unlockEndpoint(id)); + return run(() => + requestAdmin>(`/api/admin/endpoints/${encodeURIComponent(id)}/unlock`, { + method: "POST", + }), + ); } export function getRegistration(): Promise { - return run(() => mockApi.getRegistration()); + return run(() => requestAdmin("/api/admin/registration")); } export function updateRegistration(body: RegistrationUpdateRequest): Promise { - return run(() => mockApi.updateRegistration(body)); + return run(() => + requestAdmin("/api/admin/registration", { + method: "PUT", + body, + }), + ); } export function listTokens(): Promise> { - return run(() => mockApi.listTokens()); + return run(() => requestAdmin>("/api/admin/tokens")); } export function createToken(name: string): Promise { - return run(() => mockApi.createToken(name)); + return run(() => + requestAdmin("/api/admin/tokens", { + method: "POST", + body: { name }, + }), + ); } -export function patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise { - return run(() => mockApi.patchToken(id, body)); +export function patchToken( + id: string, + body: { name?: string; enabled?: boolean }, +): Promise { + return run(() => + requestAdmin(`/api/admin/tokens/${encodeURIComponent(id)}`, { + method: "PATCH", + body, + }), + ); } -export function deleteToken(id: number) { - return run(() => mockApi.deleteToken(id)); +export function deleteToken(id: string) { + return run(() => + requestAdmin>(`/api/admin/tokens/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + ); } -export function listGroups(query?: string, cursor?: string, limit?: number): Promise> { - return run(() => mockApi.listGroups(query, cursor, limit)); +export function listGroups( + query?: string, + cursor?: string, + limit?: number, +): Promise> { + return run(() => + requestAdmin>( + `/api/admin/groups${buildQuery({ query, cursor, limit })}`, + ), + ); } export function createGroup(body: { @@ -142,31 +256,74 @@ export function createGroup(body: { owner_id: string; member_ids?: string[]; }): Promise<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }> { - return run(() => mockApi.createGroup(body)); + // A3:后台建群不接受自定义 id;有值才传,否则省略字段 + const payload: Record = { + name: body.name, + owner_id: body.owner_id, + }; + if (body.member_ids?.length) payload.member_ids = body.member_ids; + if (body.id?.trim()) payload.id = body.id.trim(); + return run(() => + requestAdmin<{ id: string; name: string; owner_id: string; failed: GroupFailed[] }>( + "/api/admin/groups", + { method: "POST", body: payload }, + ), + ); } export function getGroup(id: string, cursor?: string, limit?: number): Promise { - return run(() => mockApi.getGroup(id, cursor, limit)); + return run(() => + requestAdmin( + `/api/admin/groups/${encodeURIComponent(id)}${buildQuery({ cursor, limit })}`, + ), + ); } export function renameGroup(id: string, name: string): Promise { - return run(() => mockApi.renameGroup(id, name)); + return run(() => + requestAdmin(`/api/admin/groups/${encodeURIComponent(id)}`, { + method: "PATCH", + body: { name }, + }), + ); } export function deleteGroup(id: string) { - return run(() => mockApi.deleteGroup(id)); + return run(() => + requestAdmin>(`/api/admin/groups/${encodeURIComponent(id)}`, { + method: "DELETE", + }), + ); } export function addGroupMembers(id: string, memberIds: string[]) { - return run(() => mockApi.addGroupMembers(id, memberIds)); + return run(() => + requestAdmin<{ failed: GroupFailed[] }>( + `/api/admin/groups/${encodeURIComponent(id)}/members`, + { + method: "POST", + body: { member_ids: memberIds }, + }, + ), + ); } export function removeGroupMember(id: string, endpointId: string) { - return run(() => mockApi.removeGroupMember(id, endpointId)); + return run(() => + requestAdmin>( + `/api/admin/groups/${encodeURIComponent(id)}/members/${encodeURIComponent(endpointId)}`, + { method: "DELETE" }, + ), + ); } export function transferGroup(id: string, endpointId: string) { - return run(() => mockApi.transferGroup(id, endpointId)); + return run(() => + requestAdmin<{ owner_id: string }>(`/api/admin/groups/${encodeURIComponent(id)}/transfer`, { + method: "POST", + body: { endpoint_id: endpointId }, + }), + ); } export function listMessages(q: { @@ -177,15 +334,26 @@ export function listMessages(q: { group_id?: string; state?: string; }): Promise> { - return run(() => mockApi.listMessages(q)); + return run(() => + requestAdmin>( + `/api/admin/messages${buildQuery({ + cursor: q.cursor, + limit: q.limit, + sender_id: q.sender_id, + endpoint_id: q.endpoint_id, + group_id: q.group_id, + state: q.state, + })}`, + ), + ); } export function getMessage(seq: number): Promise { - return run(() => mockApi.getMessage(seq)); + return run(() => requestAdmin(`/api/admin/messages/${seq}`)); } export function getSettings(): Promise { - return run(() => mockApi.getSettings()); + return run(() => requestAdmin("/api/admin/settings")); } export type { MeInfo }; diff --git a/web/src/api/mock.ts b/web/src/api/mock.ts index 123b3a5..7ce9b35 100644 --- a/web/src/api/mock.ts +++ b/web/src/api/mock.ts @@ -98,7 +98,7 @@ const registration: RegistrationSettings = { const tokens: ApiToken[] = [ { - id: 1, + id: "1", name: "ops", enabled: true, created_at_ms: now() - 86_400_000, @@ -557,7 +557,7 @@ export const mockApi = { async createToken(name: string): Promise { requireSession(); const item: ApiToken = { - id: nextTokenId++, + id: String(nextTokenId++), name, enabled: true, created_at_ms: now(), @@ -572,7 +572,7 @@ export const mockApi = { }; }, - async patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise { + async patchToken(id: string, body: { name?: string; enabled?: boolean }): Promise { requireSession(); const t = tokens.find((x) => x.id === id); if (!t) { @@ -583,7 +583,7 @@ export const mockApi = { return clone(t); }, - async deleteToken(id: number): Promise> { + async deleteToken(id: string): Promise> { requireSession(); const idx = tokens.findIndex((x) => x.id === id); if (idx < 0) { diff --git a/web/src/api/types.ts b/web/src/api/types.ts index def2c1d..3501fa9 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -34,6 +34,8 @@ export interface Overview { endpoints_total: number; endpoints_online: number; endpoints_disabled: number; + /** A3 补充:自助注册端数量;契约示例未列,前端可选展示 */ + endpoints_self?: number; groups_total: number; messages_pending: number; messages_scheduled: number; @@ -118,7 +120,8 @@ export interface RegistrationUpdateRequest { } export interface ApiToken { - id: number; + /** 后端为 TEXT 十六进制字符串(A1 偏差);契约示例曾写数字 */ + id: string; name: string; enabled: boolean; created_at_ms: number; @@ -126,7 +129,7 @@ export interface ApiToken { } export interface ApiTokenCreateResult { - id: number; + id: string; name: string; token: string; created_at_ms: number; diff --git a/web/src/views/EndpointsView.spec.ts b/web/src/views/EndpointsView.spec.ts index 5a9dd79..ac7616a 100644 --- a/web/src/views/EndpointsView.spec.ts +++ b/web/src/views/EndpointsView.spec.ts @@ -1,6 +1,6 @@ import { config, mount, flushPromises } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { NConfigProvider, NDialogProvider, @@ -12,6 +12,8 @@ import { defineComponent, h, nextTick } from "vue"; import EndpointsView from "./EndpointsView.vue"; import { mockApi } from "@/api/mock"; +vi.mock("@/api/admin", async () => import("@/api/admin-mock")); + config.global.stubs = { teleport: true }; function wrap(Comp: object) { diff --git a/web/src/views/GroupsView.vue b/web/src/views/GroupsView.vue index d0d9642..6c936e0 100644 --- a/web/src/views/GroupsView.vue +++ b/web/src/views/GroupsView.vue @@ -230,6 +230,7 @@ async function onTransfer(endpointId: string) { diff --git a/web/src/views/RegistrationView.vue b/web/src/views/RegistrationView.vue index 35cba73..7a4a44c 100644 --- a/web/src/views/RegistrationView.vue +++ b/web/src/views/RegistrationView.vue @@ -71,16 +71,23 @@ async function generateCode() { 开放注册 开启后需同时设置安全码,客户端注册时携带。 - + - + {{ formatLocalMs(data.updated_at_ms) }} - 保存安全码 + + 保存安全码 + 生成安全码 diff --git a/web/src/views/TokensView.spec.ts b/web/src/views/TokensView.spec.ts index 74ddc27..45a31a4 100644 --- a/web/src/views/TokensView.spec.ts +++ b/web/src/views/TokensView.spec.ts @@ -1,6 +1,6 @@ import { config, mount, flushPromises } from "@vue/test-utils"; import { createPinia, setActivePinia } from "pinia"; -import { beforeEach, describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { NConfigProvider, NDialogProvider, @@ -12,6 +12,8 @@ import { defineComponent, h, nextTick } from "vue"; import TokensView from "./TokensView.vue"; import { mockApi } from "@/api/mock"; +vi.mock("@/api/admin", async () => import("@/api/admin-mock")); + config.global.stubs = { teleport: true }; function wrap(Comp: object) { diff --git a/web/src/views/TokensView.vue b/web/src/views/TokensView.vue index c6ca66f..52a762d 100644 --- a/web/src/views/TokensView.vue +++ b/web/src/views/TokensView.vue @@ -40,7 +40,7 @@ onMounted(() => { }); const columns = computed>(() => [ - { title: "ID", key: "id", width: 60 }, + { title: "ID", key: "id", width: 140, ellipsis: { tooltip: true } }, { title: "名称", key: "name", width: 160 }, { title: "启用", diff --git a/web/vite.config.ts b/web/vite.config.ts index f1525a5..3f8c1a1 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; const rootDir = path.dirname(fileURLToPath(import.meta.url)); +const proxyTarget = process.env.NIXMSG_PROXY_TARGET || "http://127.0.0.1:7443"; export default defineConfig({ plugins: [vue()], @@ -14,8 +15,8 @@ export default defineConfig({ }, server: { proxy: { - "/api": "http://127.0.0.1:7443", - "/healthz": "http://127.0.0.1:7443", + "/api": proxyTarget, + "/healthz": proxyTarget, }, }, test: {