// SPDX-License-Identifier: AGPL-3.0-only package devserver import ( "context" "errors" "fmt" "io" "net" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "sync/atomic" "testing" "time" ) func TestSupervisorBuildsSwapsAndCleansUp(t *testing.T) { if testing.Short() { t.Skip("integration test builds temporary Go applications") } root := t.TempDir() if err := os.WriteFile(filepath.Join(root, "go.mod"), []byte("module example.test/himesan-dev-test\n\ngo 1.25\n"), 0o600); err != nil { t.Fatal(err) } mainPath := filepath.Join(root, "main.go") writeTestApplication(t, mainPath, "version one", true) cfg := DefaultConfig() cfg.ProxyAddress = "127.0.0.1:0" cfg.HealthPath = "/healthz" var generations atomic.Int32 events := make(chan Event, 32) supervisor, err := New(Options{ RootDir: root, Config: cfg, Generate: func(context.Context) error { generations.Add(1) return nil }, OnEvent: func(event Event) { events <- event }, CacheDir: filepath.Join(t.TempDir(), "cache"), PollInterval: 25 * time.Millisecond, Debounce: 25 * time.Millisecond, BuildTimeout: 30 * time.Second, StartupTimeout: 750 * time.Millisecond, ShutdownTimeout: 2 * time.Second, }) if err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(context.Background()) runResult := make(chan error, 1) go func() { runResult <- supervisor.Run(ctx) }() proxyAddress := waitForProxyAddress(t, supervisor) waitForBody(t, "http://"+proxyAddress+"/", "version one") firstUpstream := supervisor.proxy.target.Load().Host if err := os.WriteFile(mainPath, []byte("package main\nfunc"), 0o600); err != nil { t.Fatal(err) } waitForPhase(t, events, "build") waitForBody(t, "http://"+proxyAddress+"/", "version one") writeTestApplication(t, mainPath, "unhealthy candidate", false) waitForPhase(t, events, "startup") waitForBody(t, "http://"+proxyAddress+"/", "version one") writeTestApplication(t, mainPath, "version two", true) waitForBody(t, "http://"+proxyAddress+"/", "version two") if generations.Load() < 4 { t.Fatalf("Generate hook ran %d times, want at least 4", generations.Load()) } secondUpstream := supervisor.proxy.target.Load().Host if firstUpstream == secondUpstream { t.Fatalf("healthy candidate was not swapped: %s", firstUpstream) } // The proxy target changes before graceful shutdown of the replaced child // completes. Wait for that bounded cleanup instead of racing the supervisor // immediately after the first response from the new target. waitForConnectionRefused(t, firstUpstream, 3*time.Second) cancel() select { case err := <-runResult: if err != nil { t.Fatalf("Run() error = %v", err) } case <-time.After(5 * time.Second): t.Fatal("Run() did not stop after cancellation") } waitForConnectionRefused(t, secondUpstream, 3*time.Second) } func TestGenerationFailureDoesNotMoveProxyTarget(t *testing.T) { t.Parallel() upstream := http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "last good") })} listener, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = upstream.Close() }) go func() { _ = upstream.Serve(listener) }() wantError := errors.New("templates are invalid") events := make(chan Event, 1) supervisor, err := New(Options{ RootDir: t.TempDir(), Config: DefaultConfig(), Generate: func(context.Context) error { return wantError }, OnEvent: func(event Event) { events <- event }, CacheDir: filepath.Join(t.TempDir(), "cache"), }) if err != nil { t.Fatal(err) } if err := supervisor.proxy.setTarget(listener.Addr().String()); err != nil { t.Fatal(err) } wantTarget := supervisor.proxy.target.Load().String() if candidate := supervisor.buildHealthyCandidate(context.Background()); candidate != nil { t.Fatal("generation failure unexpectedly produced a candidate") } if got := supervisor.proxy.target.Load().String(); got != wantTarget { t.Fatalf("proxy target changed from %q to %q", wantTarget, got) } select { case event := <-events: if event.Type != "diagnostic" || event.Phase != "generate" || !strings.Contains(event.Message, wantError.Error()) { t.Fatalf("generation event = %#v", event) } case <-time.After(time.Second): t.Fatal("generation diagnostic was not emitted") } } func writeTestApplication(t *testing.T, path, message string, healthy bool) { t.Helper() healthStatus := "http.StatusNoContent" if !healthy { healthStatus = "http.StatusServiceUnavailable" } contents := fmt.Sprintf(`package main import ( "fmt" "net/http" "os" ) func main() { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(%s) }) mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") fmt.Fprint(w, "%s") }) if err := http.ListenAndServe(os.Getenv("HIMESAN_LISTEN_ADDR"), mux); err != nil { panic(err) } } `, healthStatus, message) if err := os.WriteFile(path, []byte(contents), 0o600); err != nil { t.Fatal(err) } } func waitForPhase(t *testing.T, events <-chan Event, phase string) { t.Helper() timer := time.NewTimer(10 * time.Second) defer timer.Stop() for { select { case event := <-events: if event.Type == "diagnostic" && event.Phase == phase { return } case <-timer.C: t.Fatalf("did not receive %s diagnostic", phase) } } } func waitForProxyAddress(t *testing.T, supervisor *Supervisor) string { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if address := supervisor.ProxyAddress(); address != "" { return address } time.Sleep(10 * time.Millisecond) } t.Fatal("development proxy did not start") return "" } func waitForBody(t *testing.T, url, want string) { t.Helper() client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: time.Second} deadline := time.Now().Add(10 * time.Second) var last string for time.Now().Before(deadline) { response, err := client.Get(url) if err == nil { body, readErr := io.ReadAll(response.Body) _ = response.Body.Close() if readErr == nil { last = string(body) if response.StatusCode == http.StatusOK && strings.Contains(last, want) && strings.Contains(last, string(reloadClientTag)) { return } } } time.Sleep(25 * time.Millisecond) } t.Fatalf("proxy never served %q; last body = %q", want, last) } func waitForConnectionRefused(t *testing.T, address string, timeout time.Duration) { t.Helper() deadline := time.Now().Add(timeout) for { connection, err := net.DialTimeout("tcp", address, 100*time.Millisecond) if err != nil { return } _ = connection.Close() if time.Now().After(deadline) { t.Fatalf("replaced child still accepts connections at %s after %s", address, timeout) } time.Sleep(25 * time.Millisecond) } } func TestLimitedDiagnosticBuffer(t *testing.T) { t.Parallel() var buffer limitedDiagnosticBuffer contents := strings.Repeat("x", maxDiagnosticOutput+100) written, err := buffer.Write([]byte(contents)) if err != nil || written != len(contents) { t.Fatalf("Write() = %d, %v", written, err) } if buffer.Buffer.Len() != maxDiagnosticOutput { t.Fatalf("stored bytes = %d, want %d", buffer.Buffer.Len(), maxDiagnosticOutput) } if !strings.HasSuffix(buffer.String(), "diagnostic output truncated") { t.Fatalf("String() did not report truncation: %q", buffer.String()) } } func TestReplaceEnvironment(t *testing.T) { t.Parallel() got := replaceEnvironment([]string{"A=one", "HIMESAN_LISTEN_ADDR=old", "B=two"}, "HIMESAN_LISTEN_ADDR", "127.0.0.1:1") want := []string{"A=one", "B=two", "HIMESAN_LISTEN_ADDR=127.0.0.1:1"} if strings.Join(got, "\x00") != strings.Join(want, "\x00") { t.Fatalf("replaceEnvironment() = %q, want %q", got, want) } } func TestHealthCheckDoesNotFollowRedirectOffLoopback(t *testing.T) { t.Parallel() upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Location", "http://example.invalid/escaped") w.WriteHeader(http.StatusFound) })) defer upstream.Close() cfg := DefaultConfig() supervisor, err := New(Options{ RootDir: t.TempDir(), Config: cfg, Generate: func(context.Context) error { return nil }, CacheDir: filepath.Join(t.TempDir(), "cache"), }) if err != nil { t.Fatal(err) } candidate := &candidateProcess{ address: strings.TrimPrefix(upstream.URL, "http://"), exited: make(chan struct{}), } ctx, cancel := context.WithTimeout(context.Background(), time.Second) defer cancel() if err := supervisor.waitUntilHealthy(ctx, candidate); err != nil { t.Fatalf("loopback redirect status should be observed without following it: %v", err) } }