package setup import ( "bytes" "context" "errors" "io" "os" "path/filepath" "strings" "testing" "time" "github.com/aritmolab/thothii/tools/tht/internal/compose" ) func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) { projectRoot, request := setupRunFixture(t, false) runner := &setupRunner{health: []string{ unhealthyServicesJSON, healthyServicesJSON, }} var output bytes.Buffer result, err := Run(context.Background(), runner, request, strings.NewReader(""), &output) if err != nil { t.Fatalf("Run() error = %v", err) } if !result.Configured || !result.Built || !result.Started || !result.Healthy { t.Fatalf("Run() result = %#v, want all lifecycle phases complete", result) } if result.ProjectName == "" || result.DescriptorPath != filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml") { t.Fatalf("Run() result = %#v, want descriptor below the project deployment directory", result) } want := []string{ "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "health", "doctor", "pi doctor", } if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") { t.Fatalf("runner stages = %v, want %v", got, want) } for _, text := range []string{"ThothII is ready", "http://127.0.0.1:8080", result.DescriptorPath} { if !strings.Contains(output.String(), text) { t.Errorf("output = %q, want %q", output.String(), text) } } } func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) { _, request := setupRunFixture(t, true) runner := &setupRunner{} result, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) if err != nil { t.Fatalf("Run() error = %v", err) } if !result.Configured || result.Built || result.Started || result.Healthy { t.Fatalf("Run() result = %#v, want only configuration", result) } want := []string{"docker engine", "docker compose", "architecture", "compose config"} if got := runner.stages; strings.Join(got, " | ") != strings.Join(want, " | ") { t.Fatalf("runner stages = %v, want %v", got, want) } } func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) { projectRoot, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "docker engine"} _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) if err == nil || !strings.Contains(err.Error(), "Docker Engine") { t.Fatalf("Run() error = %v, want Docker Engine failure", err) } if _, statErr := os.Stat(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")); !errors.Is(statErr, os.ErrNotExist) { t.Fatalf("descriptor was written after preflight failure: %v", statErr) } } func TestRunTimesOutWithPartialStartupGuidance(t *testing.T) { _, request := setupRunFixture(t, false) runner := &setupRunner{health: []string{unhealthyServicesJSON}} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) defer cancel() _, err := Run(ctx, runner, request, strings.NewReader(""), io.Discard) if err == nil { t.Fatal("Run() error = nil, want health timeout") } for _, text := range []string{"frontend", "tht logs frontend", "tht status", "left running"} { if !strings.Contains(err.Error(), text) { t.Errorf("Run() error = %q, want %q", err, text) } } } func TestRunBuildFailureDoesNotAttemptContainerStartup(t *testing.T) { _, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "compose build"} _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) if err == nil || !strings.Contains(err.Error(), "setup image build") { t.Fatalf("Run() error = %v, want original build failure", err) } if strings.Contains(err.Error(), "tht status") { t.Fatalf("Run() error = %q, must not offer partial-start recovery before compose up", err) } assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build") } func TestRunUpFailurePreservesCauseAndOffersRecovery(t *testing.T) { _, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "compose up"} _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup stack start", "core") assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up") } func TestRunAggregateDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { _, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "doctor"} _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup doctor", "core") assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor") } func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) { _, request := setupRunFixture(t, false) runner := &setupRunner{failureAt: "pi doctor"} _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) assertRecoveryFailure(t, err, "setup Pi doctor", "core") assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor", "pi doctor") } func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) { all := []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} for _, missing := range all { t.Run("missing "+missing, func(t *testing.T) { volumes := make([]string, 0, len(all)-1) for _, name := range all { if name != missing { volumes = append(volumes, name) } } if err := requireVolumes(renderedConfigForVolumes(volumes...)); err == nil || !strings.Contains(err.Error(), missing) { t.Fatalf("requireVolumes() error = %v, want missing %q", err, missing) } }) } if err := requireVolumes(renderedConfigForVolumes("unrelated")); err == nil { t.Fatal("requireVolumes() error = nil, want required-volume failure for unrelated-only configuration") } if err := requireVolumes(renderedConfigForVolumes(all...)); err != nil { t.Fatalf("requireVolumes() error = %v, want complete installation volume set", err) } } func TestRunIgnoresIrrelevantCRLFFilesDuringLineEndingCheck(t *testing.T) { projectRoot, request := setupRunFixture(t, true) irrelevant := filepath.Join(projectRoot, "node_modules", "unrelated", "generated.yml") if err := os.MkdirAll(filepath.Dir(irrelevant), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(irrelevant, []byte("generated: true\r\n"), 0o600); err != nil { t.Fatal(err) } if _, err := Run(context.Background(), &setupRunner{}, request, strings.NewReader(""), io.Discard); err != nil { t.Fatalf("Run() error = %v, want irrelevant CRLF file ignored", err) } } func assertRecoveryFailure(t *testing.T, err error, cause, service string) { t.Helper() if err == nil { t.Fatal("Run() error = nil, want failure") } for _, text := range []string{cause, "tht logs " + service, "tht status", "left running"} { if !strings.Contains(err.Error(), text) { t.Errorf("Run() error = %q, want %q", err, text) } } } func assertSetupStages(t *testing.T, runner *setupRunner, want ...string) { t.Helper() if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") { t.Fatalf("runner stages = %v, want %v", got, want) } } const unhealthyServicesJSON = `[ {"Service":"core","State":"running","Health":"healthy"}, {"Service":"frontend","State":"running","Health":"starting"}, {"Service":"qdrant","State":"running","Health":"healthy"}, {"Service":"embedding","State":"running","Health":"healthy"}, {"Service":"embedding-model-init","State":"exited","ExitCode":0} ]` const healthyServicesJSON = `[ {"Service":"core","State":"running","Health":"healthy"}, {"Service":"frontend","State":"running","Health":"healthy"}, {"Service":"qdrant","State":"running","Health":"healthy"}, {"Service":"embedding","State":"running","Health":"healthy"}, {"Service":"embedding-model-init","State":"exited","ExitCode":0} ]` type setupRunner struct { stages []string health []string failureAt string } func (r *setupRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { stage, result := setupStage(args) if stage == "doctor" && containsStage(r.stages, "doctor") { stage = "pi doctor" } r.stages = append(r.stages, stage) if stage == r.failureAt { return compose.Result{ExitCode: 41}, errors.New("fixture failure") } if stage == "health" { if len(r.health) == 0 { result.Stdout = healthyServicesJSON } else { result.Stdout, r.health = r.health[0], r.health[1:] } } return result, nil } func containsStage(stages []string, wanted string) bool { for _, stage := range stages { if stage == wanted { return true } } return false } func collapseStages(stages []string) []string { collapsed := make([]string, 0, len(stages)) for _, stage := range stages { if stage == "pi doctor" && len(collapsed) > 0 && collapsed[len(collapsed)-1] == stage { continue } collapsed = append(collapsed, stage) } return collapsed } func setupStage(args []string) (string, compose.Result) { joined := strings.Join(args, " ") switch { case joined == "version --format {{.Server.Version}}": return "docker engine", compose.Result{Stdout: "26.0.0\n"} case joined == "compose version --short": return "docker compose", compose.Result{Stdout: "v2.30.0\n"} case joined == "version --format {{.Server.Arch}}": return "architecture", compose.Result{Stdout: "arm64\n"} case strings.HasSuffix(joined, " config --quiet"): return "compose config", compose.Result{} case strings.HasSuffix(joined, " build"): return "compose build", compose.Result{} case strings.HasSuffix(joined, " up --detach --remove-orphans"): return "compose up", compose.Result{} case strings.HasSuffix(joined, " ps --all --format json"): return "health", compose.Result{} case strings.HasSuffix(joined, " config --format json"): return "doctor", compose.Result{Stdout: renderedSetupConfig} case strings.Contains(joined, " ps -q core"): return "pi doctor", compose.Result{Stdout: "core-id\n"} case strings.HasPrefix(joined, "inspect --format"): return "pi doctor", compose.Result{Stdout: "0.80.3\n"} case strings.Contains(joined, "pi --version") || strings.Contains(joined, "PI_VERSION") || strings.Contains(joined, "test -w") || strings.Contains(joined, "test -r") || strings.Contains(joined, "127.0.0.1:8787/health"): return "pi doctor", compose.Result{Stdout: "0.80.3\n"} case strings.Contains(joined, "/pi-management/test"): return "pi doctor", compose.Result{Stdout: `{"ready":true}`} default: return "unexpected: " + joined, compose.Result{} } } const renderedSetupConfig = `{"volumes":{"settings":{},"pi-state":{},"workspace-registry":{},"workspace-secrets":{},"sessions":{},"qdrant-data":{},"embedding-models":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` func renderedConfigForVolumes(volumes ...string) string { entries := make([]string, 0, len(volumes)) for _, volume := range volumes { entries = append(entries, `"`+volume+`":{}`) } return `{"volumes":{` + strings.Join(entries, ",") + `}}` } func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) { t.Helper() temporaryRoot, err := filepath.EvalSymlinks(os.TempDir()) if err != nil { t.Fatal(err) } root, err := os.MkdirTemp(temporaryRoot, "tht-setup-run-") if err != nil { t.Fatal(err) } t.Cleanup(func() { _ = os.RemoveAll(root) }) for _, directory := range []string{".git", "backend", "frontend", "harness", "tools", "deploy", "docker"} { if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { t.Fatal(err) } } for _, path := range []string{ "compose.yaml", "deploy/compose.local.yaml", "deploy/compose.server.yaml", "deploy/compose.git-https.yaml", } { if err := os.WriteFile(filepath.Join(root, path), []byte("services: {}\n"), 0o600); err != nil { t.Fatal(err) } } secrets := filepath.Join(root, "deploy", "ci", "secrets") if err := os.MkdirAll(secrets, 0o700); err != nil { t.Fatal(err) } return root, Request{ ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true, Answers: Answers{ WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https", SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"), GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"), CreateSecretTemplates: true, }, } }