package ci import ( "context" "encoding/binary" "net" "net/http" "net/http/httptest" "path" "path/filepath" "slices" "strings" "sync" "testing" "hearthforge/internal/config" ) // muxFrame builds one Docker multiplexed stream frame. func muxFrame(text string) []byte { hdr := make([]byte, 8) hdr[0] = 1 binary.BigEndian.PutUint32(hdr[4:], uint32(len(text))) return append(hdr, text...) } // newMockEngine serves a fake engine on a unix socket and returns a Runner // pointed at it. func newMockEngine(t *testing.T, h http.Handler) *Runner { t.Helper() sock := filepath.Join(t.TempDir(), "engine.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatalf("listen: %v", err) } srv := httptest.Server{Listener: ln, Config: &http.Server{Handler: h}} srv.Start() t.Cleanup(srv.Close) return New(&config.Config{CIDockerSocket: sock}, nil) } func TestExecReadsLogAndExitCode(t *testing.T) { r := newMockEngine(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch req.URL.Path { case "/v1.47/containers/abc/exec": w.Write([]byte(`{"Id":"exec1"}`)) case "/v1.47/exec/exec1/start": w.Write(muxFrame("hello ")) w.Write(muxFrame("world\n")) case "/v1.47/exec/exec1/json": w.Write([]byte(`{"ExitCode":3}`)) default: t.Errorf("unexpected request %s", req.URL.Path) } })) res, err := r.exec(context.Background(), "abc", []string{"true"}, "/ci", nil, nil) if err != nil { t.Fatalf("exec: %v", err) } if res.log != "hello world\n" || res.exitCode != 3 { t.Fatalf("got %q exit %d", res.log, res.exitCode) } } func TestSocketMissingIsReported(t *testing.T) { r := New(&config.Config{CIDockerSocket: "/nonexistent/engine.sock"}, nil) _, err := r.exec(context.Background(), "abc", []string{"true"}, "", nil, nil) if err == nil || err.Error() != errNoSocket.Error() { t.Fatalf("got %v", err) } } // A crash can leak [[copy]] helpers, which carry no run name. The startup // sweep finds them by label. func TestCancelStaleRunsSweepsLabelledContainers(t *testing.T) { var mu sync.Mutex var deleted []string r := newMockEngine(t, http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { switch { case req.Method == http.MethodGet && req.URL.Path == "/v1.47/containers/json": if !strings.Contains(req.URL.Query().Get("filters"), "com.hearthforge.ci") { t.Errorf("filters = %q", req.URL.Query().Get("filters")) } w.Write([]byte(`[{"Id":"helper1"}]`)) case req.Method == http.MethodDelete: mu.Lock() deleted = append(deleted, path.Base(req.URL.Path)) mu.Unlock() w.WriteHeader(http.StatusNotFound) default: t.Errorf("unexpected request %s %s", req.Method, req.URL.Path) } })) _, d := newRunnerDB(t) r.db = d id := insertRun(t, d, "running") if err := r.CancelStaleRuns(context.Background()); err != nil { t.Fatal(err) } mu.Lock() defer mu.Unlock() want := []string{containerName(id), "helper1"} if !slices.Equal(deleted, want) { t.Fatalf("deleted %v, want %v", deleted, want) } }