Ctrl K
ComunidadGuías…

smoke_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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() {}
}