Compare commits

...
Author SHA1 Message Date
Nixevol b75b67928b feat: 管理后台接真实接口并加 Playwright e2e 2026-09-30 09:20:01 +08:00
24 changed files with 1299 additions and 71 deletions
+3
View File
@@ -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/
+5 -1
View File
@@ -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 镜像
+26 -3
View File
@@ -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)
+31
View File
@@ -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
+151
View File
@@ -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<RuntimeInfo> {
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<void> {
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();
});
});
+55
View File
@@ -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<void>((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 });
}
+327
View File
@@ -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
}
+195
View File
@@ -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);
});
+13 -1
View File
@@ -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"],
+4 -1
View File
@@ -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",
+27
View File
@@ -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,
},
});
+28
View File
@@ -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
+178
View File
@@ -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<T>(fn: () => Promise<T>, silent = false): Promise<T> {
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<Overview> {
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 };
+210 -42
View File
@@ -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<T>(fn: () => Promise<T>, silent = false): Promise<T> {
}
}
function buildQuery(params: Record<string, string | number | boolean | undefined | null>): 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<Record<string, never>>("/api/admin/logout", { method: "POST" }));
}
export function fetchMe(silent = false) {
return run(() => mockApi.me(), silent);
return run(() => requestAdmin<MeInfo>("/api/admin/me", { silent }), silent);
}
export function changePassword(oldPassword: string, newPassword: string) {
return run(() => mockApi.changePassword(oldPassword, newPassword));
return run(() =>
requestAdmin<Record<string, never>>("/api/admin/password", {
method: "POST",
body: { old_password: oldPassword, new_password: newPassword },
}),
);
}
export function fetchOverview(): Promise<Overview> {
return run(() => mockApi.overview());
return run(() => requestAdmin<Overview>("/api/admin/overview"));
}
export function listEndpoints(q?: EndpointListQuery): Promise<PageResult<Endpoint>> {
return run(() => mockApi.listEndpoints(q));
export function listEndpoints(q: EndpointListQuery = {}): Promise<PageResult<Endpoint>> {
return run(() =>
requestAdmin<PageResult<Endpoint>>(
`/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<EndpointCreateResult> {
return run(() => mockApi.createEndpoint(body));
return run(() =>
requestAdmin<EndpointCreateResult>("/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<BatchResult> {
return run(() => mockApi.batchEndpoints(ids, action));
export function batchEndpoints(
ids: string[],
action: "disable" | "enable" | "delete",
): Promise<BatchResult> {
return run(() =>
requestAdmin<BatchResult>("/api/admin/endpoints/batch", {
method: "POST",
body: { ids, action },
}),
);
}
export function getEndpoint(id: string): Promise<Endpoint> {
return run(() => mockApi.getEndpoint(id));
return run(() => requestAdmin<Endpoint>(`/api/admin/endpoints/${encodeURIComponent(id)}`));
}
export function patchEndpoint(id: string, body: EndpointPatchRequest): Promise<Endpoint> {
return run(() => mockApi.patchEndpoint(id, body));
return run(() =>
requestAdmin<Endpoint>(`/api/admin/endpoints/${encodeURIComponent(id)}`, {
method: "PATCH",
body,
}),
);
}
export function deleteEndpoint(id: string) {
return run(() => mockApi.deleteEndpoint(id));
return run(() =>
requestAdmin<Record<string, never>>(`/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<Record<string, never>>(`/api/admin/endpoints/${encodeURIComponent(id)}/unlock`, {
method: "POST",
}),
);
}
export function getRegistration(): Promise<RegistrationSettings> {
return run(() => mockApi.getRegistration());
return run(() => requestAdmin<RegistrationSettings>("/api/admin/registration"));
}
export function updateRegistration(body: RegistrationUpdateRequest): Promise<RegistrationSettings> {
return run(() => mockApi.updateRegistration(body));
return run(() =>
requestAdmin<RegistrationSettings>("/api/admin/registration", {
method: "PUT",
body,
}),
);
}
export function listTokens(): Promise<PageResult<ApiToken>> {
return run(() => mockApi.listTokens());
return run(() => requestAdmin<PageResult<ApiToken>>("/api/admin/tokens"));
}
export function createToken(name: string): Promise<ApiTokenCreateResult> {
return run(() => mockApi.createToken(name));
return run(() =>
requestAdmin<ApiTokenCreateResult>("/api/admin/tokens", {
method: "POST",
body: { name },
}),
);
}
export function patchToken(id: number, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
return run(() => mockApi.patchToken(id, body));
export function patchToken(
id: string,
body: { name?: string; enabled?: boolean },
): Promise<ApiToken> {
return run(() =>
requestAdmin<ApiToken>(`/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<Record<string, never>>(`/api/admin/tokens/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
);
}
export function listGroups(query?: string, cursor?: string, limit?: number): Promise<PageResult<GroupSummary>> {
return run(() => mockApi.listGroups(query, cursor, limit));
export function listGroups(
query?: string,
cursor?: string,
limit?: number,
): Promise<PageResult<GroupSummary>> {
return run(() =>
requestAdmin<PageResult<GroupSummary>>(
`/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<string, unknown> = {
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<GroupDetail> {
return run(() => mockApi.getGroup(id, cursor, limit));
return run(() =>
requestAdmin<GroupDetail>(
`/api/admin/groups/${encodeURIComponent(id)}${buildQuery({ cursor, limit })}`,
),
);
}
export function renameGroup(id: string, name: string): Promise<GroupSummary> {
return run(() => mockApi.renameGroup(id, name));
return run(() =>
requestAdmin<GroupSummary>(`/api/admin/groups/${encodeURIComponent(id)}`, {
method: "PATCH",
body: { name },
}),
);
}
export function deleteGroup(id: string) {
return run(() => mockApi.deleteGroup(id));
return run(() =>
requestAdmin<Record<string, never>>(`/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<Record<string, never>>(
`/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<PageResult<MessageSummary>> {
return run(() => mockApi.listMessages(q));
return run(() =>
requestAdmin<PageResult<MessageSummary>>(
`/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<MessageDetail> {
return run(() => mockApi.getMessage(seq));
return run(() => requestAdmin<MessageDetail>(`/api/admin/messages/${seq}`));
}
export function getSettings(): Promise<RuntimeSettings> {
return run(() => mockApi.getSettings());
return run(() => requestAdmin<RuntimeSettings>("/api/admin/settings"));
}
export type { MeInfo };
+4 -4
View File
@@ -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<ApiTokenCreateResult> {
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<ApiToken> {
async patchToken(id: string, body: { name?: string; enabled?: boolean }): Promise<ApiToken> {
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<Record<string, never>> {
async deleteToken(id: string): Promise<Record<string, never>> {
requireSession();
const idx = tokens.findIndex((x) => x.id === id);
if (idx < 0) {
+5 -2
View File
@@ -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;
+3 -1
View File
@@ -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) {
+14 -5
View File
@@ -230,6 +230,7 @@ async function onTransfer(endpointId: string) {
<template #actions>
<n-button
type="primary"
data-testid="group-create-open"
@click="
Object.assign(createForm, { id: '', name: '', owner_id: '', member_ids: '' });
createOpen = true;
@@ -267,23 +268,31 @@ async function onTransfer(endpointId: string) {
<n-scrollbar style="max-height: 320px">
<n-form label-placement="left" label-width="90" :show-feedback="false">
<n-form-item label="编号">
<n-input v-model:value="createForm.id" placeholder="留空则生成" />
<n-input v-model:value="createForm.id" placeholder="留空则生成" data-testid="group-id" />
</n-form-item>
<n-form-item label="名称">
<n-input v-model:value="createForm.name" />
<n-input v-model:value="createForm.name" data-testid="group-name" />
</n-form-item>
<n-form-item label="群主编号">
<n-input v-model:value="createForm.owner_id" />
<n-input v-model:value="createForm.owner_id" data-testid="group-owner" />
</n-form-item>
<n-form-item label="成员" label-placement="top">
<n-input v-model:value="createForm.member_ids" type="textarea" :rows="3" placeholder="多个编号用逗号或空格分隔" />
<n-input
v-model:value="createForm.member_ids"
type="textarea"
:rows="3"
placeholder="多个编号用逗号或空格分隔"
data-testid="group-members"
/>
</n-form-item>
</n-form>
</n-scrollbar>
<template #footer>
<n-space justify="end">
<n-button @click="createOpen = false">取消</n-button>
<n-button type="primary" :loading="createLoading" @click="submitCreate">创建</n-button>
<n-button type="primary" :loading="createLoading" data-testid="group-create-submit" @click="submitCreate">
创建
</n-button>
</n-space>
</template>
</n-modal>
+3 -1
View File
@@ -1,7 +1,7 @@
import { config, mount, flushPromises } from "@vue/test-utils";
import { createPinia, setActivePinia } from "pinia";
import { createMemoryHistory, createRouter } from "vue-router";
import { beforeEach, describe, expect, it } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
NConfigProvider,
NDialogProvider,
@@ -14,6 +14,8 @@ import LoginView from "./LoginView.vue";
import { mockApi } from "@/api/mock";
import { useAuthStore } from "@/stores/auth";
vi.mock("@/api/admin", async () => import("@/api/admin-mock"));
config.global.stubs = { teleport: true };
function wrap(child: object) {
-3
View File
@@ -52,9 +52,6 @@ async function onSubmit() {
</n-button>
</n-space>
</n-form>
<n-text depth="3" style="display: block; margin-top: 12px; font-size: 12px">
假数据模式:用户名 admin,密码 adminpassword
</n-text>
</n-card>
</div>
</template>
+10 -3
View File
@@ -71,16 +71,23 @@ async function generateCode() {
开放注册
<HelpTip>开启后需同时设置安全码,客户端注册时携带。</HelpTip>
</template>
<n-switch :value="data.enabled" :loading="saving" @update:value="onToggle" />
<n-switch
:value="data.enabled"
:loading="saving"
data-testid="reg-enabled"
@update:value="onToggle"
/>
</n-form-item>
<n-form-item label="安全码">
<n-input v-model:value="codeDraft" placeholder="8–64 字符" />
<n-input v-model:value="codeDraft" placeholder="8–64 字符" data-testid="reg-code" />
</n-form-item>
<n-form-item label="更新时间">
<n-text>{{ formatLocalMs(data.updated_at_ms) }}</n-text>
</n-form-item>
<n-space>
<n-button type="primary" :loading="saving" @click="saveCode">保存安全码</n-button>
<n-button type="primary" :loading="saving" data-testid="reg-save-code" @click="saveCode">
保存安全码
</n-button>
<n-button :loading="saving" @click="generateCode">生成安全码</n-button>
</n-space>
</n-form>
+3 -1
View File
@@ -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) {
+1 -1
View File
@@ -40,7 +40,7 @@ onMounted(() => {
});
const columns = computed<DataTableColumns<ApiToken>>(() => [
{ title: "ID", key: "id", width: 60 },
{ title: "ID", key: "id", width: 140, ellipsis: { tooltip: true } },
{ title: "名称", key: "name", width: 160 },
{
title: "启用",
+3 -2
View File
@@ -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: {