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 }