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