From 3f0e7dc131c5f1a4a2dcb3a998763c30ca82803c Mon Sep 17 00:00:00 2001 From: mixeme Date: Sun, 9 Aug 2026 01:08:32 +0300 Subject: [PATCH] fix(e2e): wait for panel /healthz before setup (v1.0.0 gate) Supervisor RUNNING is not enough: the setup-token file is written before ListenAndServe, and Docker host-port publish can lag. Poll /healthz first, and stop ordered TestE2E steps after a failure so a nil panel cannot panic and mask the real error. Co-Authored-By: Composer Co-authored-by: Cursor --- CHANGELOG.md | 3 +++ test/e2e/main_test.go | 37 +++++++++++++++++++++++++------------ test/e2e/panel_client.go | 21 +++++++++++++++++++++ 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 957978a..e917e1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,9 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); version ### Fixed +- E2e gate: wait for host-published `/healthz` before panel setup, and stop + ordered `TestE2E` subtests after a failure so a nil panel client cannot panic + and mask the real error (release CI on both amd64 and arm64). - A send-log row could stay `queued` forever after the container was recreated. `mail.log` moved from the ephemeral `/var/log` into the data volume (`/data/log/mail.log`, `./data/log/` on the host), so the delivery lines that diff --git a/test/e2e/main_test.go b/test/e2e/main_test.go index 62883e0..bd845b7 100644 --- a/test/e2e/main_test.go +++ b/test/e2e/main_test.go @@ -87,13 +87,26 @@ type scenario struct { func TestE2E(t *testing.T) { sc := &scenario{} - t.Run("startup_processes_running", func(t *testing.T) { + // Ordered scenario: each step needs state from earlier ones. A failed + // subtest must stop the rest — otherwise sc.panel stays nil and the next + // step panics, masking the real failure (as seen on the v1.0.0 release CI). + run := func(name string, fn func(*testing.T)) { + if t.Failed() { + return + } + t.Run(name, fn) + } + + run("startup_processes_running", func(t *testing.T) { if err := checkSupervisorProcesses(h); err != nil { t.Fatal(err) } + if err := waitForPanelReady(); err != nil { + t.Fatal(err) + } }) - t.Run("setup_and_login", func(t *testing.T) { + run("setup_and_login", func(t *testing.T) { token, err := readSetupToken(h.stageDir) if err != nil { t.Fatalf("read setup token: %v", err) @@ -111,7 +124,7 @@ func TestE2E(t *testing.T) { sc.panel = p }) - t.Run("add_domain_and_publish_dkim", func(t *testing.T) { + run("add_domain_and_publish_dkim", func(t *testing.T) { id, err := sc.panel.addDomain(senderDomain) if err != nil { t.Fatalf("add domain: %v", err) @@ -137,7 +150,7 @@ func TestE2E(t *testing.T) { sc.zoneRecords = records }) - t.Run("add_application", func(t *testing.T) { + run("add_application", func(t *testing.T) { login, password, err := sc.panel.addApplication(sc.domainID, "app1", "wildcard", "") if err != nil { t.Fatalf("add application: %v", err) @@ -145,7 +158,7 @@ func TestE2E(t *testing.T) { sc.appLogin, sc.appPassword = login, password }) - t.Run("send_verify_dkim_and_status", func(t *testing.T) { + run("send_verify_dkim_and_status", func(t *testing.T) { token := uniqueToken("positive") res := attemptSend(sendAttempt{ authLogin: sc.appLogin, authPassword: sc.appPassword, @@ -179,25 +192,25 @@ func TestE2E(t *testing.T) { } }) - t.Run("negative_level2_ratelimit_via_panel", func(t *testing.T) { + run("negative_level2_ratelimit_via_panel", func(t *testing.T) { testLevel2RateLimit(t, sc) }) - t.Run("negative_sender_login_mismatch", func(t *testing.T) { + run("negative_sender_login_mismatch", func(t *testing.T) { testSenderLoginMismatch(t, sc) }) - t.Run("negative_no_auth_rejected", func(t *testing.T) { + run("negative_no_auth_rejected", func(t *testing.T) { testNoAuthRejected(t, sc) }) - t.Run("negative_foreign_relay_rejected", func(t *testing.T) { + run("negative_foreign_relay_rejected", func(t *testing.T) { testForeignRelayRejected(t, sc) }) - t.Run("negative_journal_milter_fail_open", func(t *testing.T) { + run("negative_journal_milter_fail_open", func(t *testing.T) { testJournalMilterFailOpen(t, sc) }) - t.Run("session_survives_restart", func(t *testing.T) { + run("session_survives_restart", func(t *testing.T) { testSessionSurvivesRestart(t, sc) }) - t.Run("negative_level1_ratelimit", func(t *testing.T) { + run("negative_level1_ratelimit", func(t *testing.T) { testLevel1RateLimit(t, sc) }) } diff --git a/test/e2e/panel_client.go b/test/e2e/panel_client.go index a42ab54..b65e70a 100644 --- a/test/e2e/panel_client.go +++ b/test/e2e/panel_client.go @@ -39,6 +39,27 @@ func newPanelClient() (*panelClient, error) { }}, nil } +// waitForPanelReady polls the host-published panel port until /healthz +// returns 200. Supervisor reporting the panel process RUNNING is not enough: +// the setup-token file is written in Start() before ListenAndServe, and +// Docker's host-port publish can lag the in-container bind — either race +// makes the first setup POST fail and leaves sc.panel nil for later steps. +func waitForPanelReady() error { + client := &http.Client{Timeout: 2 * time.Second} + return waitFor("panel /healthz on "+panelBaseURL, 60*time.Second, 200*time.Millisecond, func() (bool, error) { + resp, err := client.Get(panelBaseURL + "/healthz") + if err != nil { + return false, err + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("status %d", resp.StatusCode) + } + return true, nil + }) +} + // readSetupToken reads the one-time setup URL SelfPost wrote to /data (bind // mounted at stageDir/data/setup-token) and returns just the token. func readSetupToken(stageDir string) (string, error) {