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) } } } 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":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` 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, }, } }