Files
ThothII/tools/tht/internal/setup/run_test.go
T

229 lines
8.2 KiB
Go

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,
},
}
}