test: Go/JS SDK 接入清单集成测与 README 示例

This commit is contained in:
Nixevol
2026-09-30 08:35:09 +08:00
parent 77d2dbddb1
commit 2f750bcb0f
12 changed files with 1881 additions and 6 deletions
+297
View File
@@ -0,0 +1,297 @@
package nixmsg_test
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
// 集成测试启动器:在仓库根目录编译 nixmsg,临时目录 + 127.0.0.1:0 + admin init。
// 不依赖根模块 harness 包(sdk/go 是独立模块,从本目录起测时 go.mod 会挡住 Binary 找根)。
type itestServer struct {
BinPath string
ConfigPath string
DataDir string
Addr string
HTTPBase string
AdminHTTPBase string
AdminPassword string
MQTTWS string
cmd *exec.Cmd
}
var (
itestBinOnce sync.Once
itestBinPath string
itestBinErr error
)
func repoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
nix := filepath.Join(dir, "cmd", "nixmsg")
mod := filepath.Join(dir, "go.mod")
if st, e := os.Stat(nix); e == nil && st.IsDir() {
if b, e2 := os.ReadFile(mod); e2 == nil && bytes.Contains(b, []byte("module git.asio.asia/nixevol/NixMsg\n")) {
return dir
}
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("找不到仓库根(含 cmd/nixmsg)")
}
dir = parent
}
}
func itestBinary(t *testing.T) string {
t.Helper()
itestBinOnce.Do(func() {
root := ""
dir, err := os.Getwd()
if err != nil {
itestBinErr = err
return
}
for {
nix := filepath.Join(dir, "cmd", "nixmsg")
mod := filepath.Join(dir, "go.mod")
if st, e := os.Stat(nix); e == nil && st.IsDir() {
if b, e2 := os.ReadFile(mod); e2 == nil && bytes.Contains(b, []byte("module git.asio.asia/nixevol/NixMsg\n")) {
root = dir
break
}
}
parent := filepath.Dir(dir)
if parent == dir {
itestBinErr = fmt.Errorf("repo root not found")
return
}
dir = parent
}
tmp, err := os.MkdirTemp("", "nixmsg-sdk-go-bin-*")
if err != nil {
itestBinErr = err
return
}
name := "nixmsg"
if runtime.GOOS == "windows" {
name += ".exe"
}
out := filepath.Join(tmp, name)
cmd := exec.Command("go", "build", "-o", out, "./cmd/nixmsg")
cmd.Dir = root
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
if b, e := cmd.CombinedOutput(); e != nil {
itestBinErr = fmt.Errorf("build nixmsg: %w\n%s", e, b)
return
}
itestBinPath = out
})
if itestBinErr != nil {
t.Fatal(itestBinErr)
}
return itestBinPath
}
func startITestServer(t *testing.T) *itestServer {
t.Helper()
bin := itestBinary(t)
dataDir, err := os.MkdirTemp("", "nixmsg-sdk-go-itest-*")
if err != nil {
t.Fatal(err)
}
cfgPath := filepath.Join(dataDir, "config.yaml")
cfg := fmt.Sprintf("listen: %q\ndata_dir: %q\n", "127.0.0.1:0", filepath.ToSlash(dataDir))
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
_ = os.RemoveAll(dataDir)
t.Fatal(err)
}
initCmd := exec.Command(bin, "admin", "init")
initCmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath)
initOut, initErr := initCmd.CombinedOutput()
if initErr != nil {
_ = os.RemoveAll(dataDir)
t.Fatalf("admin init: %v\n%s", initErr, initOut)
}
pass := parseAdminPassword(string(initOut))
if pass == "" {
_ = os.RemoveAll(dataDir)
t.Fatalf("admin init 未打印密码:\n%s", initOut)
}
cmd := exec.Command(bin, "serve")
cmd.Env = append(os.Environ(), "NIXMSG_CONFIG="+cfgPath)
cmd.Stdout = os.Stderr
cmd.Stderr = os.Stderr
if err := cmd.Start(); err != nil {
_ = os.RemoveAll(dataDir)
t.Fatal(err)
}
s := &itestServer{
BinPath: bin,
ConfigPath: cfgPath,
DataDir: dataDir,
AdminPassword: pass,
cmd: cmd,
}
addr, err := waitAddrFile(filepath.Join(dataDir, "listen.addr"), 15*time.Second)
if err != nil {
_ = s.Stop()
t.Fatalf("wait listen.addr: %v", err)
}
s.Addr = addr
s.HTTPBase = "http://" + addr
s.AdminHTTPBase = s.HTTPBase
s.MQTTWS = "ws://" + addr + "/mqtt"
t.Cleanup(func() { _ = s.Stop() })
return s
}
func (s *itestServer) Stop() error {
if s == nil || s.cmd == nil {
return nil
}
if s.cmd.Process != nil {
_ = s.cmd.Process.Kill()
_, _ = s.cmd.Process.Wait()
}
s.cmd = nil
if s.DataDir != "" {
return os.RemoveAll(s.DataDir)
}
return nil
}
func parseAdminPassword(out string) string {
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
lower := strings.ToLower(line)
if strings.HasPrefix(lower, "admin password:") {
return strings.TrimSpace(line[len("admin password:"):])
}
if strings.HasPrefix(lower, "password:") {
return strings.TrimSpace(line[len("password:"):])
}
}
return ""
}
func waitAddrFile(path string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout)
var last error
for time.Now().Before(deadline) {
b, err := os.ReadFile(path)
if err == nil {
addr := strings.TrimSpace(string(b))
if addr != "" {
return addr, nil
}
last = fmt.Errorf("empty addr")
} else {
last = err
}
time.Sleep(20 * time.Millisecond)
}
return "", last
}
type adminHTTP struct {
base string
hc *http.Client
}
func newAdminHTTP(t *testing.T, base, password string) *adminHTTP {
t.Helper()
jar, err := cookiejar.New(nil)
if err != nil {
t.Fatal(err)
}
a := &adminHTTP{
base: strings.TrimRight(base, "/"),
hc: &http.Client{Timeout: 30 * time.Second, Jar: jar},
}
body, _ := json.Marshal(map[string]string{"username": "admin", "password": password})
res, err := a.do(http.MethodPost, "/api/admin/login", body, "application/json")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
raw, _ := io.ReadAll(res.Body)
t.Fatalf("admin login %d %s", res.StatusCode, raw)
}
return a
}
func (a *adminHTTP) do(method, path string, body []byte, ct string) (*http.Response, error) {
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, a.base+path, rdr)
if err != nil {
return nil, err
}
if ct != "" {
req.Header.Set("Content-Type", ct)
}
switch strings.ToUpper(method) {
case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete:
req.Header.Set("X-Nixmsg-Request", "1")
}
return a.hc.Do(req)
}
func (a *adminHTTP) putRegistration(t *testing.T, enabled bool, code string) {
t.Helper()
payload := map[string]any{"enabled": enabled}
if code != "" {
payload["code"] = code
}
raw, _ := json.Marshal(payload)
res, err := a.do(http.MethodPut, "/api/admin/registration", raw, "application/json")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
b, _ := io.ReadAll(res.Body)
if res.StatusCode != http.StatusOK {
t.Fatalf("put registration %d %s", res.StatusCode, b)
}
}
func (a *adminHTTP) kick(t *testing.T, endpointID string) {
t.Helper()
res, err := a.do(http.MethodPost, "/api/admin/endpoints/"+endpointID+"/kick", []byte("{}"), "application/json")
if err != nil {
t.Fatal(err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
t.Fatalf("kick %s: %d %s", endpointID, res.StatusCode, b)
}
}
func delay0() *time.Duration {
d := time.Duration(0)
return &d
}