Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions container/internal/cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
115 changes: 115 additions & 0 deletions container/internal/handlers/test.go
Original file line number Diff line number Diff line change
@@ -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})
}
13 changes: 13 additions & 0 deletions container/test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion container/test/data/gh_data/auth_status.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"hostname": "github.com",
"username": "testuser",
"scopes": ["repo", "workflow", "user"]
"scopes": ["repo", "workflow"]
}
1 change: 1 addition & 0 deletions container/test/docker-compose.test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
93 changes: 81 additions & 12 deletions container/test/mocks/claude
Original file line number Diff line number Diff line change
Expand Up @@ -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" <<EOF
{"type":"assistant","timestamp":"$now","message":{"role":"assistant","content":[{"type":"tool_use","name":"TodoWrite","input":{"todos":[{"id":"1","content":"$title","status":"pending","priority":"medium"}]}}]}}
EOF

echo "📝 Wrote TodoWrite to $session_file for title '$title'" >> "$MOCK_LOG"
}

# Get mock response for various prompts
get_response_for_prompt() {
local prompt="$1"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading