162 lines
4.2 KiB
Go
162 lines
4.2 KiB
Go
package handoff_test
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"chat-agent-system/internal/handoff"
|
|
)
|
|
|
|
// TestRealFDHandoffE2E builds maintpage, hands off a live listener, then
|
|
// starts a stub server via the maintpage admin token — zero-downtime port reuse.
|
|
func TestRealFDHandoffE2E(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("real handoff e2e")
|
|
}
|
|
modRoot, err := filepath.Abs("../..")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tmp := t.TempDir()
|
|
maintBin := filepath.Join(tmp, "maintpage")
|
|
stubBin := filepath.Join(tmp, "stubserver")
|
|
|
|
build := func(out, pkg string) {
|
|
cmd := exec.Command("go", "build", "-o", out, pkg)
|
|
cmd.Dir = modRoot
|
|
if o, err := cmd.CombinedOutput(); err != nil {
|
|
t.Fatalf("build %s: %v\n%s", pkg, err, o)
|
|
}
|
|
}
|
|
build(maintBin, "./cmd/maintpage")
|
|
|
|
// stub must live inside module for internal import
|
|
stubPkgDir := filepath.Join(modRoot, "cmd", "_handoff_e2e_stub")
|
|
_ = os.RemoveAll(stubPkgDir)
|
|
if err := os.MkdirAll(stubPkgDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer os.RemoveAll(stubPkgDir)
|
|
if err := os.WriteFile(filepath.Join(stubPkgDir, "main.go"), []byte(`package main
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"chat-agent-system/internal/handoff"
|
|
)
|
|
func main() {
|
|
ln, err := handoff.ListenOrAdopt("127.0.0.1:0")
|
|
if err != nil { fmt.Fprintln(os.Stderr, err); os.Exit(1) }
|
|
_ = handoff.SignalReady()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
|
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "{\"ok\":true}") })
|
|
go func() { _ = http.Serve(ln, mux) }()
|
|
select {}
|
|
}
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
build(stubBin, "./cmd/_handoff_e2e_stub")
|
|
|
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addr := ln.Addr().String()
|
|
token := fmt.Sprintf("e2e-%d", time.Now().UnixNano())
|
|
dataDir := filepath.Join(tmp, "data")
|
|
_ = os.MkdirAll(dataDir, 0o700)
|
|
|
|
extra := []string{
|
|
"MAINT_LISTEN_ADDR=" + addr,
|
|
"MAINT_MESSAGE=handoff-e2e",
|
|
"MAINT_ADMIN_TOKEN=" + token,
|
|
"MAINT_WINDOW_ID=1",
|
|
"MAINT_SERVER_BIN=" + stubBin,
|
|
"DATA_DIR=" + dataDir,
|
|
}
|
|
cmd, err := handoff.PassToChild(ln, maintBin, nil, extra, 20*time.Second)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
_, _ = cmd.Process.Wait()
|
|
}
|
|
// stub may have replaced maintpage; best-effort kill by port holders is out of scope —
|
|
// stub exits when we kill its process via token start response pid if present.
|
|
})
|
|
_ = ln.Close()
|
|
|
|
deadline := time.Now().Add(8 * time.Second)
|
|
var status map[string]any
|
|
for time.Now().Before(deadline) {
|
|
resp, err := http.Get("http://" + addr + "/api/v1/maintenance/status")
|
|
if err == nil {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode == 200 && json.Unmarshal(body, &status) == nil && status["active"] == true {
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
if status["active"] != true {
|
|
t.Fatalf("maintpage not active: %+v", status)
|
|
}
|
|
|
|
req, _ := http.NewRequest(http.MethodPost, "http://"+addr+"/api/v1/maintenance/admin/start", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
startBody, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Fatalf("start: %d %s", resp.StatusCode, startBody)
|
|
}
|
|
var startResp struct {
|
|
OK bool `json:"ok"`
|
|
PID int `json:"pid"`
|
|
}
|
|
_ = json.Unmarshal(startBody, &startResp)
|
|
if startResp.PID > 0 {
|
|
t.Cleanup(func() {
|
|
proc, err := os.FindProcess(startResp.PID)
|
|
if err == nil && proc != nil {
|
|
_ = proc.Kill()
|
|
}
|
|
})
|
|
}
|
|
|
|
deadline = time.Now().Add(8 * time.Second)
|
|
ok := false
|
|
for time.Now().Before(deadline) {
|
|
resp, err := http.Get("http://" + addr + "/api/v1/health")
|
|
if err == nil {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
var h map[string]any
|
|
if resp.StatusCode == 200 && json.Unmarshal(body, &h) == nil && h["ok"] == true {
|
|
ok = true
|
|
break
|
|
}
|
|
}
|
|
time.Sleep(200 * time.Millisecond)
|
|
}
|
|
if !ok {
|
|
t.Fatal("stub server health not ok after handoff resume")
|
|
}
|
|
}
|