feat: 管理后台接真实接口并加 Playwright e2e
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user