diff --git a/.forgejo/workflows/cli-e2e.yaml b/.forgejo/workflows/cli-e2e.yaml new file mode 100644 index 0000000..e92dd2a --- /dev/null +++ b/.forgejo/workflows/cli-e2e.yaml @@ -0,0 +1,83 @@ +name: CLI E2E + +on: + pull_request: + paths: + - "go.mod" + - "go.sum" + - "cmd/**" + - "internal/**" + - "proto/**" + - "gen/go/**" + - "test/e2e/**" + - "dev/docker-compose.e2e.yaml" + - ".forgejo/workflows/cli-e2e.yaml" + push: + branches: ["main"] + paths: + - "go.mod" + - "go.sum" + - "cmd/**" + - "internal/**" + - "proto/**" + - "gen/go/**" + - "test/e2e/**" + - "dev/docker-compose.e2e.yaml" + - ".forgejo/workflows/cli-e2e.yaml" + workflow_dispatch: + +jobs: + e2e: + runs-on: docker-amd64-dind + container: + image: ghcr.io/catthehacker/ubuntu:act-latest + + steps: + - name: Checkout + # renovate: datasource=git-refs depName=actions/checkout packageName=https://forgejo.alexma.top/actions/checkout currentValue=v6.0.3 + uses: https://forgejo.alexma.top/actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 + + - name: Verify Docker access + run: | + docker version + docker compose version + + - name: Install Go + # renovate: datasource=git-refs depName=actions/setup-go packageName=https://forgejo.alexma.top/actions/setup-go currentValue=v6.1.0 + uses: https://forgejo.alexma.top/actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 + with: + go-version-file: go.mod + + - name: Start real backend services + run: docker compose -f dev/docker-compose.e2e.yaml up -d --build controller-dev agent-dev + + - name: Wait for controller + run: | + for attempt in $(seq 1 60); do + if curl -fsS \ + -H 'Authorization: Bearer dev-admin-token' \ + -H 'Connect-Protocol-Version: 1' \ + -H 'Content-Type: application/json' \ + -H 'X-Composia-Source: cli-e2e' \ + --data '{}' \ + http://dind:7001/api/controller/composia.controller.v1.SystemService/GetSystemStatus >/dev/null; then + exit 0 + fi + sleep 2 + done + docker compose -f dev/docker-compose.e2e.yaml logs --no-color controller-dev agent-dev + exit 1 + + - name: Run CLI e2e + env: + COMPOSIA_E2E_CONTROLLER_ADDR: http://dind:7001 + COMPOSIA_E2E_ACCESS_TOKEN: dev-admin-token + run: go test -tags=e2e ./test/e2e + + - name: Show backend logs + if: always() + run: docker compose -f dev/docker-compose.e2e.yaml logs --no-color controller-dev agent-dev + + - name: Stop backend services + if: always() + run: docker compose -f dev/docker-compose.e2e.yaml down -v diff --git a/package.json b/package.json index cb2e86a..b7bfb26 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "web:preview": "bun run --cwd web preview", "web:check": "bun run --cwd web check", "web:e2e": "bun run --cwd web e2e", + "cli:e2e": "go test -tags=e2e ./test/e2e", "web:format": "bun run --cwd web format", "web:format:write": "bun run --cwd web format:write", "docs:api:generate": "sh ./scripts/docs/generate-proto-docs.sh", diff --git a/test/e2e/cli_test.go b/test/e2e/cli_test.go new file mode 100644 index 0000000..0b8047b --- /dev/null +++ b/test/e2e/cli_test.go @@ -0,0 +1,162 @@ +//go:build e2e + +package e2e + +import ( + "context" + "encoding/json" + "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 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 +} diff --git a/test/e2e/doc.go b/test/e2e/doc.go new file mode 100644 index 0000000..df8caf7 --- /dev/null +++ b/test/e2e/doc.go @@ -0,0 +1 @@ +package e2e