package main import ( "encoding/json" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "testing" "time" ) // TestBuildAndBoot builds the binary and boots it against a temp data dir on a // high loopback port, then checks the dashboard and API actually serve and that // a clean shutdown exits zero. It is the end-to-end guard the unit tests can't // be: wiring that compiles but wedges at startup (a bad handler mount, a store // that won't open) shows up here and nowhere else. It NEVER binds :9000 (the // port a real install owns). Skips gracefully when the Go toolchain or a free // high port is unavailable. func TestBuildAndBoot(t *testing.T) { if testing.Short() { t.Skip("skipping build-and-boot smoke test in -short mode") } goBin, err := exec.LookPath("go") if err != nil { t.Skip("go toolchain not on PATH; skipping build-and-boot smoke test") } dir := t.TempDir() bin := filepath.Join(dir, "pingularity") if runtime.GOOS == "windows" { bin += ".exe" } // Build the current package (the test runs in the repo root, where main lives). build := exec.Command(goBin, "build", "-o", bin, ".") build.Stderr = os.Stderr if err := build.Run(); err != nil { t.Fatalf("build failed: %v", err) } port, releasePort := reserveHighPort(t) addr := "127.0.0.1:" + port dbPath := filepath.Join(dir, "smoke.db") cmd := exec.Command(bin, "run", "-listen", addr, "-db", dbPath) stderr, _ := cmd.StderrPipe() // Free the reserved port the instant before the child binds it. Holding the // listener open through all the setup above means nothing else on the host // could have grabbed the port meanwhile, so the close-then-reuse window shrinks // to this single statement (a fully gap-free handoff would need the child to // inherit the listener fd, which the `run` command doesn't accept). releasePort() if err := cmd.Start(); err != nil { t.Fatalf("start failed: %v", err) } // Drain stderr so the child never blocks on a full pipe, and surface it if the // test fails. var errBuf []byte drained := make(chan struct{}) go func() { errBuf, _ = io.ReadAll(stderr); close(drained) }() // Ensure the process is reaped even on an early failure. killed := false defer func() { if !killed { _ = cmd.Process.Kill() _ = cmd.Wait() } }() base := "http://" + addr client := &http.Client{Timeout: 2 * time.Second} // Poll until the API answers or we give up. deadline := time.Now().Add(15 * time.Second) var ready bool for time.Now().Before(deadline) { resp, err := client.Get(base + "/api/status") if err == nil { io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode == http.StatusOK { ready = true break } } if cmd.ProcessState != nil { break // process already exited } time.Sleep(100 * time.Millisecond) } if !ready { <-drained t.Fatalf("daemon did not serve /api/status within the deadline; stderr:\n%s", errBuf) } // /api/status is JSON with the fields the dashboard reads. resp, err := client.Get(base + "/api/status") if err != nil { t.Fatalf("GET /api/status: %v", err) } var status map[string]any dec := json.NewDecoder(resp.Body) decErr := dec.Decode(&status) resp.Body.Close() if decErr != nil { t.Fatalf("decode /api/status: %v", decErr) } if _, ok := status["online"]; !ok { t.Errorf("/api/status missing \"online\" field: %v", status) } // A fresh database must boot with the first-run Quick Setup offer open AND // monitoring held behind it (the paused power button). This is the one test // that drives run()'s actual wiring: EnsureQuickSetupOffer at boot and the // monitoringLive hold - a refactor that drops either leaves the feature // dead or the hold fictional with every unit test still green. if v, _ := status["quick_setup_pending"].(bool); !v { t.Errorf("fresh boot: quick_setup_pending = %v, want true", status["quick_setup_pending"]) } if v, _ := status["paused"].(bool); !v { t.Errorf("fresh boot: paused = %v, want true (monitoring must hold behind the offer)", status["paused"]) } // The dashboard HTML is served at the root. rootResp, err := client.Get(base + "/") if err != nil { t.Fatalf("GET /: %v", err) } rootBody, _ := io.ReadAll(rootResp.Body) rootResp.Body.Close() if rootResp.StatusCode != http.StatusOK { t.Errorf("GET / status = %d, want 200", rootResp.StatusCode) } if len(rootBody) == 0 { t.Error("GET / returned an empty body") } // Shut down and assert a clean exit. On Unix the interactive service returns // zero on SIGINT/SIGTERM; on Windows there is no graceful signal, so just kill. if runtime.GOOS == "windows" { _ = cmd.Process.Kill() killed = true _ = cmd.Wait() return } if err := cmd.Process.Signal(os.Interrupt); err != nil { t.Fatalf("signal: %v", err) } waitErr := make(chan error, 1) go func() { waitErr <- cmd.Wait() }() select { case err := <-waitErr: killed = true if err != nil { <-drained t.Fatalf("expected a clean exit after SIGINT, got %v; stderr:\n%s", err, errBuf) } case <-time.After(10 * time.Second): _ = cmd.Process.Kill() killed = true <-waitErr t.Fatal("daemon did not exit within 10s of SIGINT") } } // reserveHighPort reserves a currently-free loopback port in the 19000-19999 // range (never 9000, which a real install owns) and returns it with a release // func that frees it. Unlike a bind-then-immediately-close probe, the listener is // held OPEN until release is called, so no other process can take the port in the // meantime; the caller releases it immediately before starting the child, keeping // the close-then-reuse window as small as possible. release is idempotent and // also wired to t.Cleanup, so the port is freed even if the caller never starts // the child. Skips if none is free. func reserveHighPort(t *testing.T) (port string, release func()) { t.Helper() for p := 19000; p < 20000; p++ { l, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", p)) if err == nil { closed := false release = func() { if !closed { // called once before Start and once via Cleanup; guard the double close closed = true l.Close() } } t.Cleanup(release) return strconv.Itoa(p), release } } t.Skip("no free port in 19000-19999") return "", func() {} }