From 3cc3cb319670dc58a8320fa404a6ca3792ba30f5 Mon Sep 17 00:00:00 2001 From: Zhe Li Date: Wed, 19 Aug 2026 13:44:56 +0200 Subject: [PATCH] fix(ui): keep the start-handler test from booting a real Canton stack TestStart_StoppedNoContainers202 drove the handler's bring-up branch against real Docker. startBringUp launches RunUp on a detached context.Background() goroutine, so the test asserted 202 and returned while the goroutine went on to pull images and start a full Canton LocalNet under the compose project canton-pebble. Those containers outlived the test. On the next run containers.List found them, the handler took the fast-start branch instead, and the test failed with 204 rather than 202 -- permanently, on any host that had run it once. It only ever passed on a clean machine, which is why CI stayed green and local runs did not. Route the three Docker-backed calls in the start path through package level seams, matching the existing collectDoctor/captureCantonPorts convention in this package, and stub them in the test. The test now also asserts that the bring-up actually ran with the seeded version and profiles, so the 202 is pinned to the right branch rather than to a status code that both branches could produce. --- internal/ui/handlers/instances.go | 14 +++++-- internal/ui/handlers/stopstart_test.go | 52 ++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/internal/ui/handlers/instances.go b/internal/ui/handlers/instances.go index defb5a8c..3e80ee11 100644 --- a/internal/ui/handlers/instances.go +++ b/internal/ui/handlers/instances.go @@ -173,6 +173,14 @@ func handleStopInstance() http.HandlerFunc { // // The 204-vs-202 split lets the frontend branch: 204 → just refetch; // 202 → open the existing create-progress modal. +// Indirected so tests can drive the start path without booting a real +// Canton stack, which outlives the request and skews every later run. +var ( + listContainers = containers.List + runStart = localnet.RunStart + runUp = localnet.RunUp +) + func handleStartInstance(hub *stream.Hub) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name") @@ -206,7 +214,7 @@ func handleStartInstance(hub *stream.Hub) http.HandlerFunc { // stopped / failed / partial: fast-start iff the containers // still exist; otherwise fall back to a full bring-up. - existing, listErr := containers.List(r.Context(), state.ComposeProject) + existing, listErr := listContainers(r.Context(), state.ComposeProject) if listErr == nil && len(existing) > 0 { ctx, cancel := context.WithTimeout(r.Context(), downTimeout) defer cancel() @@ -216,7 +224,7 @@ func handleStartInstance(hub *stream.Hub) http.HandlerFunc { // The Progress sink is only used on RunStart's up-fallback // path, which the containers-present branch can't reach, so // a discard sink is safe here. - exit := localnet.RunStart(ctx, + exit := runStart(ctx, localnet.NewTextProgress(io.Discard, io.Discard), &outBuf, &errBuf, &localnet.StartOptions{Name: name, SkipWait: true}) @@ -268,7 +276,7 @@ func startBringUp(w http.ResponseWriter, name, version string, profiles []string defer hub.ClearBuffer(topic) defer jobs.Unregister(name) prog := progress.New(hub, name) - exitCode := localnet.RunUp(jobCtx, prog, opts) + exitCode := runUp(jobCtx, prog, opts) log.Printf("start (bring-up) instance %q: exit_code=%d", name, exitCode) }() diff --git a/internal/ui/handlers/stopstart_test.go b/internal/ui/handlers/stopstart_test.go index ffe077b0..8993dcdc 100644 --- a/internal/ui/handlers/stopstart_test.go +++ b/internal/ui/handlers/stopstart_test.go @@ -1,10 +1,16 @@ package handlers import ( + "context" + "encoding/json" + "io" "net/http" "net/http/httptest" "testing" + "time" + "github.com/bitdynamics-ab/canton-devkit/internal/localnet" + "github.com/bitdynamics-ab/canton-devkit/internal/localnet/containers" "github.com/bitdynamics-ab/canton-devkit/internal/registry" "github.com/bitdynamics-ab/canton-devkit/internal/ui/stream" ) @@ -23,6 +29,29 @@ func stopStartMux(t *testing.T) *httptest.Server { return srv } +// Without stubbing, the bring-up branch really boots Canton, and the +// containers it leaves behind push every later run down the fast-start +// branch instead. +func stubStartPath(t *testing.T, existing []containers.Info) chan *localnet.UpOptions { + t.Helper() + upOpts := make(chan *localnet.UpOptions, 1) + origList, origUp, origStart := listContainers, runUp, runStart + t.Cleanup(func() { listContainers, runUp, runStart = origList, origUp, origStart }) + + listContainers = func(context.Context, string) ([]containers.Info, error) { + return existing, nil + } + runUp = func(_ context.Context, _ localnet.Progress, opts *localnet.UpOptions) int { + upOpts <- opts + return localnet.ExitSuccess + } + runStart = func(context.Context, localnet.Progress, io.Writer, io.Writer, *localnet.StartOptions) int { + t.Error("fast-start ran; with no containers the handler must bring up instead") + return localnet.ExitSuccess + } + return upOpts +} + func TestStop_InvalidName400(t *testing.T) { t.Setenv("CANTON_DEVKIT_REGISTRY", t.TempDir()) srv := stopStartMux(t) @@ -109,6 +138,7 @@ func TestStart_StoppedNoContainers202(t *testing.T) { if err := registry.Write(state); err != nil { t.Fatalf("write seeded profiles: %v", err) } + upOpts := stubStartPath(t, nil) srv := stopStartMux(t) resp, err := http.Post(srv.URL+"/api/instances/pebble/start", "application/json", nil) if err != nil { @@ -121,6 +151,28 @@ func TestStart_StoppedNoContainers202(t *testing.T) { if h := resp.Header.Get("Content-Type"); h != "application/json" { t.Errorf("Content-Type = %q, want application/json", h) } + var body struct { + Instance string `json:"instance"` + EventsURL string `json:"events_url"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.EventsURL != "/api/instances/pebble/events" { + t.Errorf("events_url = %q", body.EventsURL) + } + + select { + case opts := <-upOpts: + if opts.Name != "pebble" || opts.Version != "0.6.4" { + t.Errorf("bring-up opts = %q/%q, want pebble/0.6.4", opts.Name, opts.Version) + } + if len(opts.Profiles) != 5 { + t.Errorf("bring-up profiles = %v, want the 5 seeded", opts.Profiles) + } + case <-time.After(5 * time.Second): + t.Fatal("bring-up never ran") + } } // TestStartStub503WithoutHub — without a hub the /start route is