diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..f7707b037 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,79 @@ +name: Integration & E2E + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: 'pnpm' + + - name: Install pnpm + run: | + corepack enable + corepack prepare pnpm@9 --activate + + - name: Install frontend deps + run: pnpm install --frozen-lockfile + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build integration test image + run: | + docker build -t catnip:latest -f container/Dockerfile . + docker build -t catnip:test -f container/test/Dockerfile.test . + + - name: Start test container + working-directory: container/test + run: docker-compose -f docker-compose.test.yml up -d --no-build + + - name: Wait for health + run: | + for i in {1..30}; do + if curl -fsS http://localhost:8181/health >/dev/null; then exit 0; fi + sleep 2 + done + echo "Server failed to start" >&2 + docker-compose -f container/test/docker-compose.test.yml logs || true + exit 1 + + - name: Run API integration tests + working-directory: container/test/integration + run: | + sudo apt-get update && sudo apt-get install -y golang-go + export CATNIP_TEST_MODE=1 + export CATNIP_TEST_SERVER_URL="http://localhost:8181" + export CATNIP_TEST_DATA_DIR="$GITHUB_WORKSPACE/container/test/data" + go test -v -timeout 30m -tags=integration ./... + + - name: Install Playwright Browsers + run: pnpm dlx playwright install --with-deps chromium + + - name: Run Playwright E2E + env: + CATNIP_TEST_SERVER_URL: http://localhost:8181 + run: pnpm test:e2e + + - name: Upload Playwright report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report + + - name: Teardown + if: always() + working-directory: container/test + run: docker-compose -f docker-compose.test.yml down diff --git a/container/internal/cmd/serve.go b/container/internal/cmd/serve.go index 70d520429..30a8d67fa 100644 --- a/container/internal/cmd/serve.go +++ b/container/internal/cmd/serve.go @@ -256,6 +256,15 @@ func startServer(cmd *cobra.Command) { v1.Post("/ports/mappings", portsHandler.SetPortMapping) v1.Delete("/ports/mappings/:port", portsHandler.DeletePortMapping) + // Test-only helpers (enabled when CATNIP_TEST_MODE=1) + if os.Getenv("CATNIP_TEST_MODE") == "1" { + testHandler := handlers.NewTestHandler(gitService, claudeMonitor) + v1.Post("/test/worktrees/:id/title", testHandler.SimulateTitle) + v1.Post("/test/worktrees/:id/file", testHandler.SimulateFile) + v1.Post("/test/worktrees/:id/rename", testHandler.SimulateRename) + log.Printf("๐Ÿงช Test endpoints enabled") + } + // Server info route v1.Get("/info", func(c *fiber.Ctx) error { commit, date, builtBy := GetBuildInfo() diff --git a/container/internal/handlers/test.go b/container/internal/handlers/test.go new file mode 100644 index 000000000..a93c0b7f1 --- /dev/null +++ b/container/internal/handlers/test.go @@ -0,0 +1,115 @@ +package handlers + +import ( + "io/fs" + "os" + "path/filepath" + + "github.com/gofiber/fiber/v2" + "github.com/vanpelt/catnip/internal/models" + "github.com/vanpelt/catnip/internal/services" +) + +// TestHandler exposes test-only helpers when CATNIP_TEST_MODE=1 +type TestHandler struct { + gitService *services.GitService + claudeMonitor *services.ClaudeMonitorService +} + +func NewTestHandler(git *services.GitService, monitor *services.ClaudeMonitorService) *TestHandler { + return &TestHandler{gitService: git, claudeMonitor: monitor} +} + +type simulateTitleRequest struct { + Title string `json:"title"` + Todos []models.Todo `json:"todos"` +} + +// SimulateTitle applies a session title and optional todos to a worktree +func (h *TestHandler) SimulateTitle(c *fiber.Ctx) error { + worktreeID := c.Params("id") + if worktreeID == "" { + return c.Status(400).JSON(fiber.Map{"error": "worktree id is required"}) + } + + var req simulateTitleRequest + if err := c.BodyParser(&req); err != nil || req.Title == "" { + return c.Status(400).JSON(fiber.Map{"error": "title is required"}) + } + + wt, ok := h.gitService.GetWorktree(worktreeID) + if !ok || wt == nil { + return c.Status(404).JSON(fiber.Map{"error": "worktree not found"}) + } + + // Notify title change (updates session service and may trigger rename) + h.claudeMonitor.NotifyTitleChange(wt.Path, req.Title) + + // Optionally set todos directly in state for UI + if len(req.Todos) > 0 { + _ = h.gitService.UpdateWorktreeFields(worktreeID, map[string]interface{}{ + "todos": req.Todos, + }) + } + + return c.JSON(fiber.Map{"ok": true}) +} + +type simulateFileRequest struct { + RelPath string `json:"path"` + Content string `json:"content"` +} + +// SimulateFile writes a file within the worktree to create a change +func (h *TestHandler) SimulateFile(c *fiber.Ctx) error { + worktreeID := c.Params("id") + if worktreeID == "" { + return c.Status(400).JSON(fiber.Map{"error": "worktree id is required"}) + } + var req simulateFileRequest + if err := c.BodyParser(&req); err != nil || req.RelPath == "" { + return c.Status(400).JSON(fiber.Map{"error": "path is required"}) + } + wt, ok := h.gitService.GetWorktree(worktreeID) + if !ok || wt == nil { + return c.Status(404).JSON(fiber.Map{"error": "worktree not found"}) + } + + // Ensure directory exists and write file + target := filepath.Join(wt.Path, req.RelPath) + if err := os.MkdirAll(filepath.Dir(target), fs.FileMode(0755)); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + if err := os.WriteFile(target, []byte(req.Content), 0644); err != nil { + return c.Status(500).JSON(fiber.Map{"error": err.Error()}) + } + + // Refresh status so UI picks up dirtiness and diffs + _ = h.gitService.RefreshWorktreeStatusByID(worktreeID) + + return c.JSON(fiber.Map{"ok": true}) +} + +type simulateRenameRequest struct { + Branch string `json:"branch"` +} + +// SimulateRename triggers automatic or custom branch rename +func (h *TestHandler) SimulateRename(c *fiber.Ctx) error { + worktreeID := c.Params("id") + if worktreeID == "" { + return c.Status(400).JSON(fiber.Map{"error": "worktree id is required"}) + } + var req simulateRenameRequest + _ = c.BodyParser(&req) + + wt, ok := h.gitService.GetWorktree(worktreeID) + if !ok || wt == nil { + return c.Status(404).JSON(fiber.Map{"error": "worktree not found"}) + } + + if err := h.claudeMonitor.TriggerBranchRename(wt.Path, req.Branch); err != nil { + return c.Status(400).JSON(fiber.Map{"error": err.Error()}) + } + return c.JSON(fiber.Map{"ok": true}) +} diff --git a/container/test/README.md b/container/test/README.md index 3e89d7976..3e6a29461 100644 --- a/container/test/README.md +++ b/container/test/README.md @@ -142,6 +142,19 @@ The integration tests cover these major areas: ./run_integration_tests.sh stop ``` +### End-to-End (Playwright) + +The runner now also executes Playwright UI tests against the same test container: + +```bash +# Ensure container is up, then run UI tests only +./run_integration_tests.sh start +pnpm dlx playwright install --with-deps chromium +CATNIP_TEST_SERVER_URL=http://localhost:8181 pnpm test:e2e +``` + +In CI, the `Integration & E2E` workflow builds the test container, runs API integration tests, installs Playwright, and runs E2E against `http://localhost:8181`. + ### Development Workflow The new architecture provides excellent development experience: diff --git a/container/test/data/gh_data/auth_status.json b/container/test/data/gh_data/auth_status.json index c315d72a3..84354ec11 100644 --- a/container/test/data/gh_data/auth_status.json +++ b/container/test/data/gh_data/auth_status.json @@ -1,5 +1,5 @@ { "hostname": "github.com", "username": "testuser", - "scopes": ["repo", "workflow", "user"] + "scopes": ["repo", "workflow"] } diff --git a/container/test/docker-compose.test.yml b/container/test/docker-compose.test.yml index 3955ad146..c29219207 100644 --- a/container/test/docker-compose.test.yml +++ b/container/test/docker-compose.test.yml @@ -16,6 +16,7 @@ services: environment: - CATNIP_TEST_MODE=1 - PORT=8181 + - CATNIP_TEST_AIR=${CATNIP_TEST_AIR:-0} # Speed up cache operations for faster tests - CATNIP_CACHE_DEBOUNCE_MS=50 - CATNIP_CACHE_BATCH_MS=25 diff --git a/container/test/mocks/claude b/container/test/mocks/claude index ddce88269..f36f52e28 100755 --- a/container/test/mocks/claude +++ b/container/test/mocks/claude @@ -42,6 +42,38 @@ send_title_escape() { echo "๐Ÿชง New terminal title detected: $title" >> "$MOCK_LOG" } +# Write a minimal TodoWrite JSONL entry under ~/.claude/projects for current workdir +write_todos_jsonl() { + local title="$1" + local home_dir="${HOME:-/home/catnip}" + local workdir + workdir=$(pwd) + # Transform workdir to Claude projects dir name: replace / with - and ensure leading dash + local transformed + transformed=$(echo "$workdir" | sed 's#/#-#g') + case "$transformed" in + -*) : ;; # already has leading dash + *) transformed="-$transformed" ;; + esac + local project_dir="$home_dir/.claude/projects/$transformed" + mkdir -p "$project_dir" 2>/dev/null || true + + # Choose or create a session file + local session_file + session_file="$project_dir/$(get_session_uuid).jsonl" + + # Current RFC3339 timestamp + local now + now=$(date -u +"%Y-%m-%dT%H:%M:%SZ") + + # Append a TodoWrite assistant message containing a single todo related to the title + cat >>"$session_file" <> "$MOCK_LOG" +} + # Get mock response for various prompts get_response_for_prompt() { local prompt="$1" @@ -167,6 +199,8 @@ handle_test_command() { send_title_escape "$new_title" echo "๐Ÿชง Set terminal title to: $new_title" save_session_state "$(get_session_uuid)" "$new_title" + # Also write todos to drive UI updates in tests + write_todos_jsonl "$new_title" ;; *) return 1 # Not a test command @@ -310,18 +344,53 @@ main() { # Determine mode based on parsed flags if [[ "$api_mode" == true ]]; then - # API mode - subprocess communication - echo "API mode detected, streaming mock response" >> "$MOCK_LOG" 2>/dev/null || true - - # Sleep briefly to simulate processing - sleep 1 - - # Stream the JSON response format expected by subprocess - # Use a simple, clean JSON response - cat << 'EOF' -{"type": "assistant", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hello World function created successfully!"}]}} -EOF - echo "Mock sent simple JSON response" >> "$MOCK_LOG" 2>/dev/null || true + # API mode - subprocess communication: read one JSON line from stdin + echo "API mode detected, reading stdin for prompt" >> "$MOCK_LOG" 2>/dev/null || true + + # Read a single JSON line from stdin + local input_line + IFS= read -r input_line || true + echo "API input: $input_line" >> "$MOCK_LOG" 2>/dev/null || true + + # Extract prompt content from JSON + local prompt="" + if [[ -n "$input_line" ]]; then + prompt=$(echo "$input_line" | python3 -c " +import json, sys +try: + data = json.load(sys.stdin) + if 'message' in data and 'content' in data['message']: + print(data['message']['content']) +except Exception: + pass +" 2>/dev/null) + fi + + # Helper to slugify a title into a branch-friendly string + slugify_title() { + echo "$1" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9\- ]/ /g' | tr -s ' ' '-' | sed 's/^-\+//; s/\-\+$//' | cut -c1-60 + } + + # If this is a branch-name generation request, return a valid branch name + if [[ "$prompt" == *"Generate a git branch name"* || "$prompt" == *"Based on this coding session title:"* ]]; then + local title + title=$(echo "$prompt" | sed -n 's/.*Based on this coding session title: \"\(.*\)\".*/\1/p') + if [[ -z "$title" ]]; then + title="$prompt" + fi + local branch + branch="feature/$(slugify_title "$title")" + if [[ -z "$branch" || "$branch" == "feature/" ]]; then + branch="feature/mock-branch" + fi + printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"%s"}]}}\n' "$branch" + echo "Mock sent branch name: $branch" >> "$MOCK_LOG" 2>/dev/null || true + return + fi + + # Default simple JSON response + printf '{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Hello from mock Claude!"}]}}\n' + echo "Mock sent generic JSON response" >> "$MOCK_LOG" 2>/dev/null || true return elif [[ ! -t 0 ]]; then # JSON input mode (piped input) diff --git a/container/test/run_integration_tests.sh b/container/test/run_integration_tests.sh index 0b287c054..116817035 100755 --- a/container/test/run_integration_tests.sh +++ b/container/test/run_integration_tests.sh @@ -94,6 +94,8 @@ start_test_container() { # Start the container using docker-compose # No need to build, just use the existing catnip:test image + # Default to disabling Air unless explicitly enabled by caller + export CATNIP_TEST_AIR=${CATNIP_TEST_AIR:-0} docker-compose -f docker-compose.test.yml up -d --no-build # Wait for the container to be healthy @@ -152,8 +154,7 @@ run_tests() { # Ensure test container is running ensure_test_container - # Run the tests from the host using Go installed locally or in a runner container - # For now, we'll use a simple approach - create a minimal test runner + # Run the API tests from host using Go installed locally or in a runner container cd "$SCRIPT_DIR/integration" # Set environment variables for tests to point to our test server @@ -163,7 +164,7 @@ run_tests() { # Check if go is available locally if command -v go >/dev/null 2>&1; then - log_info "Running tests with local Go installation..." + log_info "Running API integration tests with local Go installation..." go test -v -timeout 30m -tags=integration ./... 2>&1 else log_info "Go not found locally, using Docker to run tests..." @@ -180,14 +181,34 @@ run_tests() { go test -v -timeout 30m -tags=integration ./... fi - local test_exit_code=$? + local api_test_exit_code=$? - if [ $test_exit_code -eq 0 ]; then - log_success "All integration tests passed!" + if [ $api_test_exit_code -ne 0 ]; then + log_error "API integration tests failed with exit code: $api_test_exit_code" + return $api_test_exit_code + fi + + # Run Playwright E2E against running test server if Node is available + if command -v pnpm >/dev/null 2>&1; then + log_info "Running Playwright E2E tests with pnpm..." + cd "$PROJECT_ROOT" + # Ensure Playwright is installed (idempotent); install browsers if missing + if ! pnpm dlx playwright --version >/dev/null 2>&1; then + pnpm dlx playwright install --with-deps chromium + else + pnpm dlx playwright install --with-deps chromium + fi + CATNIP_TEST_SERVER_URL="http://localhost:$TEST_PORT" pnpm test:e2e + local e2e_exit_code=$? + if [ $e2e_exit_code -ne 0 ]; then + log_error "Playwright E2E tests failed with exit code: $e2e_exit_code" + return $e2e_exit_code + fi else - log_error "Integration tests failed with exit code: $test_exit_code" - return $test_exit_code + log_warning "pnpm not found; skipping Playwright E2E tests." fi + + log_success "All integration tests (API + E2E) passed!" } # Function to run specific test diff --git a/container/test/scripts/test-entrypoint.sh b/container/test/scripts/test-entrypoint.sh index 79236dc11..e2f6e0101 100644 --- a/container/test/scripts/test-entrypoint.sh +++ b/container/test/scripts/test-entrypoint.sh @@ -22,12 +22,13 @@ trap cleanup SIGTERM SIGINT # Change to the mounted project directory cd /live/catnip/container -# Download Go dependencies (will be fast due to pre-warmed cache) -echo "๐Ÿ“ฆ Installing Go dependencies..." -go mod download +# If running with Air, prepare config (optional; default is disabled) +if [[ "${CATNIP_TEST_AIR}" == "1" ]]; then + # Download Go dependencies (will be fast due to pre-warmed cache) + echo "๐Ÿ“ฆ Installing Go dependencies..." + go mod download -# Create a test-specific .air.toml configuration -cat > .air.test.toml << 'EOF' + cat > .air.test.toml << 'EOF' root = "." testdata_dir = "testdata" tmp_dir = "tmp" @@ -75,6 +76,7 @@ tmp_dir = "tmp" clear_on_rebuild = false keep_scroll = true EOF +fi # Create test live repository for preview branch testing echo "๐Ÿ“‚ Creating test live repository..." @@ -142,18 +144,18 @@ cd /live/catnip/container export CATNIP_TEST_MODE=1 export PORT=8181 -# Start Go server with Air hot reloading on test port -echo "โšก Starting Go test server with hot reloading on port 8181..." -air -c .air.test.toml & -GO_PID=$! - -echo "โœ… Test environment ready!" -echo " ๐Ÿ”ง Test Server: http://localhost:8181 (with Air hot reloading)" -echo " ๐Ÿ“š API Docs: http://localhost:8181/swagger/" -echo "" -echo "๐Ÿ”ฅ Hot Module Replacement (HMR) enabled:" -echo " โ€ข Backend: Air watching for Go file changes" -echo " โ€ข Make changes to container/ files to see live updates!" - -# Wait for the process -wait \ No newline at end of file +# Start server +if [[ "${CATNIP_TEST_AIR}" == "1" ]]; then + echo "โšก Starting Go test server with Air on port 8181..." + air -c .air.test.toml & + GO_PID=$! + echo "โœ… Test environment ready with Air" + echo " ๐Ÿ”ง Test Server: http://localhost:8181 (Air hot reloading)" + echo " ๐Ÿ“š API Docs: http://localhost:8181/swagger/" + echo "" + echo "๐Ÿ”ฅ HMR enabled for backend via Air" + wait +else + echo "๐Ÿš€ Starting Catnip server (no Air) on port 8181..." + exec /opt/catnip/bin/catnip serve +fi \ No newline at end of file diff --git a/package.json b/package.json index 52e429d10..3790aca35 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "format:check": "prettier --check .", "format:changed": "{ git diff --cached --name-only --diff-filter=ACMR; git diff --name-only --diff-filter=ACMR; git ls-files --others --exclude-standard; } | grep -E '\\.(ts|tsx|js|jsx|json|css|md)$' | xargs -r prettier --write", "preview": "pnpm run build && vite preview", + "test:e2e": "playwright test", + "test:e2e:headed": "PLAYWRIGHT_HEADLESS=0 playwright test -g workspace", "deploy:prod": "pnpm run build && wrangler deploy --env production", "cf-typegen": "wrangler types", "test": "vitest", @@ -103,6 +105,7 @@ "typescript": "~5.8.3", "typescript-eslint": "^8.38.0", "vite": "^7.0.6", + "@playwright/test": "^1.47.2" "vitest": "^3.2.4", "wrangler": "^4.27.0" } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 000000000..bd3ad3850 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,22 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + timeout: 90_000, + expect: { timeout: 10_000 }, + fullyParallel: true, + retries: process.env.CI ? 2 : 0, + reporter: [["html", { open: "never" }], ["list"]], + use: { + baseURL: process.env.CATNIP_TEST_SERVER_URL || "http://localhost:8181", + trace: "retain-on-failure", + video: "retain-on-failure", + screenshot: "only-on-failure", + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aeac9d794..1d727b5c0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: '@inquirer/prompts': specifier: ^7.8.0 version: 7.8.0(@types/node@24.1.0) + '@playwright/test': + specifier: ^1.47.2 + version: 1.54.2 '@tanstack/router-plugin': specifier: ^1.130.12 version: 1.130.12(@tanstack/react-router@1.130.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(vite@7.0.6(@types/node@24.1.0)(jiti@2.5.1)(lightningcss@1.30.1)(tsx@4.20.3)) @@ -1252,6 +1255,11 @@ packages: resolution: {integrity: sha512-gcK4FNaROM9NjA0mvyfXl0KPusk7a1BeA8ITlYEZVQCXF5gcETTd4yhAU0Kjzd8mXwYHppzJBWgdBVpIR9wUcQ==} engines: {node: '>= 20'} + '@playwright/test@1.54.2': + resolution: {integrity: sha512-A+znathYxPf+72riFd1r1ovOLqsIIB0jKIoPjyK2kqEIe30/6jF6BC7QNluHuwUmsD2tv1XZVugN8GqfTMOxsA==} + engines: {node: '>=18'} + hasBin: true + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -2838,10 +2846,16 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3483,6 +3497,16 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + playwright-core@1.54.2: + resolution: {integrity: sha512-n5r4HFbMmWsB4twG7tJLDN9gmBUeSPcsBZiWSE4DnYz9mJMAFqr2ID7+eGC9kpEnxExJ1epttwR59LEWCk8mtA==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.54.2: + resolution: {integrity: sha512-Hu/BMoA1NAdRUuulyvQC0pEqZ4vQbGfn8f7wPXcnqQmM+zct9UliKxsIkLNmz/ku7LElUNqmaiv1TG/aL5ACsw==} + engines: {node: '>=18'} + hasBin: true + postcss-selector-parser@6.0.10: resolution: {integrity: sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==} engines: {node: '>=4'} @@ -5158,6 +5182,10 @@ snapshots: '@octokit/request-error': 7.0.0 '@octokit/webhooks-methods': 6.0.0 + '@playwright/test@1.54.2': + dependencies: + playwright: 1.54.2 + '@pkgjs/parseargs@0.11.0': optional: true @@ -7594,6 +7622,14 @@ snapshots: picomatch@4.0.3: {} + playwright-core@1.54.2: {} + + playwright@1.54.2: + dependencies: + playwright-core: 1.54.2 + optionalDependencies: + fsevents: 2.3.2 + postcss-selector-parser@6.0.10: dependencies: cssesc: 3.0.0 diff --git a/tests/e2e/workspace-basic.spec.ts b/tests/e2e/workspace-basic.spec.ts new file mode 100644 index 000000000..ffe943622 --- /dev/null +++ b/tests/e2e/workspace-basic.spec.ts @@ -0,0 +1,169 @@ +import { test, expect } from "@playwright/test"; + +// Utility: waits for a condition polling to avoid flakiness +async function waitFor( + fn: () => Promise, + predicate: (value: T) => boolean, + timeoutMs = 20000, + intervalMs = 250, +): Promise { + const start = Date.now(); + // eslint-disable-next-line no-constant-condition + while (true) { + const val = await fn(); + if (predicate(val)) return val; + if (Date.now() - start > timeoutMs) throw new Error("waitFor timeout"); + await new Promise((r) => setTimeout(r, intervalMs)); + } +} + +// E2E happy-path using the mocked claude/gh in the integration test container +// Steps: +// - Ensure test container is up at CATNIP_TEST_SERVER_URL +// - Checkout a local live repo via API to force a default workspace +// - Navigate to /workspace and auto-redirect to first workspace +// - Interact with Claude terminal via websocket: send setTitle to trigger title + todos +// - Verify title appears in header, todos render, changed files non-empty +// - Verify branch auto-renamed (no catnip/ prefix) in right sidebar + +test("workspace boot + claude interaction updates UI and branch rename", async ({ + page, + request, +}) => { + const baseURL = process.env.CATNIP_TEST_SERVER_URL || "http://localhost:8181"; + + // 1) Ensure the server is healthy (retry to handle restarts) + await waitFor( + async () => { + try { + const h = await request.get(`${baseURL}/health`); + return h.ok(); + } catch { + return false as any; + } + }, + (ok) => ok === true, + 30000, + 500, + ); + + // 2) Create (or reuse) a worktree from our live test repo + // This endpoint exists in integration tests and will create initial worktree + // Retry checkout in case server is rebuilding + const checkoutJson = await waitFor( + async () => { + try { + const resp = await request.post( + `${baseURL}/v1/git/checkout/local/test-live-repo`, + { data: {} }, + ); + if (resp.ok()) return await resp.json(); + return null as any; + } catch { + return null as any; + } + }, + (v) => !!v, + 60000, + 1000, + ); + const worktree = checkoutJson.worktree; + expect(worktree).toBeTruthy(); + + // 3) Navigate directly to the created workspace route to avoid redirect flakiness + const [project, ws] = (worktree.name as string).split("/"); + await page.goto(`${baseURL}/workspace/${project}/${ws}`); + + // Wait for either loading to disappear or Claude header to appear + await Promise.race([ + page.getByText("Claude").first().waitFor(), + page.getByText("Loading workspace").first().waitFor({ state: "hidden" }), + ]); + + // 4) Use test-only endpoints to avoid brittle terminal input + const simulateTitle = async (title: string) => { + const res = await request.post( + `${baseURL}/v1/test/worktrees/${worktree.id}/title`, + { + data: { + title, + todos: [ + { id: "1", content: title, status: "pending", priority: "medium" }, + ], + }, + }, + ); + expect(res.ok()).toBeTruthy(); + }; + await simulateTitle("Implement login flow"); + + // Wait for session title to be reflected in API before checking UI + await waitFor( + async () => { + const wtResp = await request.get(`${baseURL}/v1/git/worktrees`); + if (!wtResp.ok()) return "" as any; + const arr = (await wtResp.json()) as any[]; + const self = arr.find((w) => w.path === worktree.path); + const title = self?.session_title?.title || ""; + return title; + }, + (title: string) => title.includes("Implement login flow"), + 30000, + 500, + ); + + // 5) Verify header shows the new session title (WorkspaceMainContent shows "- {title}" after Claude label) + await expect(page.getByText("- Implement login flow")).toBeVisible({ + timeout: 30000, + }); + + // 6) Verify todos appear in right sidebar by content text + await waitFor( + async () => page.getByText("Implement login flow").count(), + (cnt) => cnt > 0, + 30000, + 500, + ); + + // 7) Verify changed files populated (Changed Files badge not 0) + // Create a real file change so diff list is non-empty + // Create a file change via helper + const resFile = await request.post( + `${baseURL}/v1/test/worktrees/${worktree.id}/file`, + { + data: { path: "e2e.txt", content: "hello from e2e" }, + }, + ); + expect(resFile.ok()).toBeTruthy(); + + // Wait for Changed Files by checking diff API directly for this worktree + await waitFor( + async () => { + const diffResp = await request.get( + `${baseURL}/v1/git/worktrees/${worktree.id}/diff`, + ); + if (!diffResp.ok()) return 0 as any; + const diff = await diffResp.json(); + const count = Array.isArray(diff?.file_diffs) + ? diff.file_diffs.length + : 0; + return count; + }, + (count: number) => count > 0, + 60000, + 500, + ); + + // 8) Verify branch gets auto-renamed (no catnip/) via API to avoid flaky DOM selectors + await waitFor( + async () => { + const wtResp = await request.get(`${baseURL}/v1/git/worktrees`); + const arr = wtResp.ok() ? ((await wtResp.json()) as any[]) : []; + const self = arr.find((w) => w.path === worktree.path); + return self?.branch || ""; + }, + (branch: string) => !!branch && !branch.includes("catnip/"), + 60_000, + 500, + ); +});