A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241//go:build e2e
package e2e
import ( "context" "encoding/json" "errors" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "testing" "time")
const ( defaultControllerAddr = "http://127.0.0.1:7001" defaultAccessToken = "dev-admin-token")
type cliResult struct { stdout string stderr string err error}
func TestCLIConnectsToRealController(t *testing.T) { bin := buildCLI(t) env := cliEnv(t)
waitForController(t, bin, env)
status := runCLI(t, bin, env, "--output", "json", "system", "status") if status.err != nil { t.Fatalf("system status failed: %v\nstdout:\n%s\nstderr:\n%s", status.err, status.stdout, status.stderr) } statusJSON := decodeJSON(t, status.stdout) if version, ok := statusJSON["version"].(string); !ok || version == "" { t.Fatalf("expected system status version, got:\n%s", status.stdout) }
nodes := runCLI(t, bin, env, "--output", "json", "node", "list") if nodes.err != nil { t.Fatalf("node list failed: %v\nstdout:\n%s\nstderr:\n%s", nodes.err, nodes.stdout, nodes.stderr) } nodesJSON := decodeJSON(t, nodes.stdout) if !hasNodeID(nodesJSON, "main") { t.Fatalf("expected node list to include main, got:\n%s", nodes.stdout) }}
func TestCLISafeRepoCRUDAndInvalidToken(t *testing.T) { bin := buildCLI(t) env := cliEnv(t)
waitForController(t, bin, env)
badEnv := replaceEnv(env, "COMPOSIA_ACCESS_TOKEN", "invalid-token") invalid := runCLI(t, bin, badEnv, "system", "status") if invalid.err == nil { t.Fatalf("system status with invalid token succeeded") } if code := exitCode(invalid.err); code == 0 { t.Fatalf("invalid token exit code = 0, stderr:\n%s", invalid.stderr) } if invalid.stdout != "" { t.Fatalf("invalid token stdout = %q", invalid.stdout) } if !strings.Contains(strings.ToLower(invalid.stderr), "unauthenticated") { t.Fatalf("invalid token stderr missing unauthenticated error:\n%s", invalid.stderr) }
root := fmt.Sprintf("cli-e2e-%d", time.Now().UnixNano()) contentPath := filepath.Join(t.TempDir(), "hello.txt") if err := os.WriteFile(contentPath, []byte("hello from cli e2e\n"), 0o600); err != nil { t.Fatalf("write content file: %v", err) }
for _, step := range []struct { name string args []string }{ {"mkdir", []string{"repo", "mkdir", "--message", "cli e2e mkdir", root}}, {"update", []string{"repo", "update", "--file", contentPath, "--message", "cli e2e update", root + "/hello.txt"}}, {"mv", []string{"repo", "mv", "--yes", "--message", "cli e2e mv", root + "/hello.txt", root + "/renamed.txt"}}, } { result := runCLI(t, bin, env, step.args...) if result.err != nil { t.Fatalf("repo %s failed: %v\nstdout:\n%s\nstderr:\n%s", step.name, result.err, result.stdout, result.stderr) } }
got := runCLI(t, bin, env, "repo", "get", root+"/renamed.txt") if got.err != nil || got.stdout != "hello from cli e2e\n" { t.Fatalf("repo get failed: %v\nstdout:\n%s\nstderr:\n%s", got.err, got.stdout, got.stderr) }
files := runCLI(t, bin, env, "repo", "files", "--recursive", root) if files.err != nil || !strings.Contains(files.stdout, root+"/renamed.txt") { t.Fatalf("repo files failed: %v\nstdout:\n%s\nstderr:\n%s", files.err, files.stdout, files.stderr) }
removed := runCLI(t, bin, env, "repo", "rm", "--yes", "--message", "cli e2e rm", root) if removed.err != nil { t.Fatalf("repo rm failed: %v\nstdout:\n%s\nstderr:\n%s", removed.err, removed.stdout, removed.stderr) }}
func buildCLI(t *testing.T) string { t.Helper()
bin := filepath.Join(t.TempDir(), "composia") if runtime.GOOS == "windows" { bin += ".exe" }
cmd := exec.Command("go", "build", "-o", bin, "./cmd/composia") cmd.Dir = repoRoot(t) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("build CLI: %v\n%s", err, out) }
return bin}
func repoRoot(t *testing.T) string { t.Helper()
root, err := filepath.Abs(filepath.Join("..", "..")) if err != nil { t.Fatalf("resolve repo root: %v", err) } return root}
func cliEnv(t *testing.T) []string { t.Helper()
addr := strings.TrimSpace(os.Getenv("COMPOSIA_E2E_CONTROLLER_ADDR")) if addr == "" { addr = defaultControllerAddr } token := strings.TrimSpace(os.Getenv("COMPOSIA_E2E_ACCESS_TOKEN")) if token == "" { token = defaultAccessToken }
home := t.TempDir() env := append(os.Environ(), "COMPOSIA_CONTROLLER_ADDR="+addr, "COMPOSIA_ACCESS_TOKEN="+token, "HOME="+home, "XDG_CONFIG_HOME="+filepath.Join(home, ".config"), ) return env}
func waitForController(t *testing.T, bin string, env []string) { t.Helper()
deadline := time.Now().Add(60 * time.Second) var last cliResult for time.Now().Before(deadline) { last = runCLI(t, bin, env, "--output", "json", "system", "status") if last.err == nil { return } time.Sleep(2 * time.Second) }
t.Fatalf("controller did not become ready: %v\nstdout:\n%s\nstderr:\n%s", last.err, last.stdout, last.stderr)}
func runCLI(t *testing.T, bin string, env []string, args ...string) cliResult { t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel()
cmd := exec.CommandContext(ctx, bin, args...) cmd.Env = env
var stdout, stderr strings.Builder cmd.Stdout = &stdout cmd.Stderr = &stderr
err := cmd.Run() if ctx.Err() != nil { err = fmt.Errorf("CLI timed out: %w", ctx.Err()) } return cliResult{stdout: stdout.String(), stderr: stderr.String(), err: err}}
func decodeJSON(t *testing.T, input string) map[string]any { t.Helper()
var decoded map[string]any if err := json.Unmarshal([]byte(input), &decoded); err != nil { t.Fatalf("decode JSON: %v\n%s", err, input) } return decoded}
func hasNodeID(input map[string]any, nodeID string) bool { nodes, ok := input["nodes"].([]any) if !ok { return false } for _, item := range nodes { node, ok := item.(map[string]any) if ok && node["node_id"] == nodeID { return true } } return false}
func replaceEnv(env []string, key, value string) []string { prefix := key + "=" replaced := append([]string(nil), env...) for i, item := range replaced { if strings.HasPrefix(item, prefix) { replaced[i] = prefix + value return replaced } } return append(replaced, prefix+value)}
func exitCode(err error) int { var exitErr *exec.ExitError if errors.As(err, &exitErr) { return exitErr.ExitCode() } return -1}