diff --git a/.github/linters/.golangci.yml b/.github/linters/.golangci.yml index 60e61e0af..e6fdaaa07 100644 --- a/.github/linters/.golangci.yml +++ b/.github/linters/.golangci.yml @@ -1,41 +1,24 @@ -# golangci-lint configuration -# See: https://golangci-lint.run/usage/configuration/ +# GolangCI-Lint configuration for LeafLock backend +# Centralized under .github/linters for CI reuse -# This version is required for golangci-lint v2.x+ version: "2" run: timeout: 5m tests: true - # Use issues.exclude-dirs instead of skip-dirs (deprecated) - -issues: - # Exclude directories from linting - exclude-dirs: - - .gomod - - vendor - - node_modules - - frontend - exclude-files: - - ".*_test.go" - - ".*.pb.go" - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 linters: + default: none enable: + - errcheck - govet - - staticcheck - ineffassign + - staticcheck - unused - - errcheck - # Note: Only using core linters compatible with all golangci-lint versions -linters-settings: - govet: - check-shadowing: true - gofmt: - simplify: true - staticcheck: - checks: ["all"] +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +severity: + default: error diff --git a/.github/linters/.sqlfluff b/.github/linters/.sqlfluff index 220e63e74..671420560 100644 --- a/.github/linters/.sqlfluff +++ b/.github/linters/.sqlfluff @@ -1,46 +1,29 @@ -# SQLFluff Configuration +# SQLFluff configuration centralized for CI # See: https://docs.sqlfluff.com/en/stable/configuration.html [sqlfluff] -# Set dialect to PostgreSQL dialect = postgres - -# Exclude directories exclude_rules = L034,L031 - -# Templater configuration templater = raw - -# File encoding encoding = utf-8 [sqlfluff:indentation] -# Indentation rules indented_joins = false indented_using_on = true template_blocks_indent = true [sqlfluff:layout:type:comma] -# Comma placement spacing_before = touch line_position = trailing [sqlfluff:rules] -# Allow longer lines for complex queries max_line_length = 120 -# Disable overly strict rules -# L034: Select wildcards (SELECT *) - common in migrations -# L031: Avoid aliases in FROM/JOIN - too strict for complex queries - [sqlfluff:rules:L010] -# Capitalisation of keywords capitalisation_policy = upper [sqlfluff:rules:L014] -# Unquoted identifiers extended_capitalisation_policy = lower [sqlfluff:rules:L030] -# Function names capitalisation_policy = upper diff --git a/.github/workflows/claude.yml b/.github/workflows/automation-claude-assistant.yml similarity index 56% rename from .github/workflows/claude.yml rename to .github/workflows/automation-claude-assistant.yml index 664125165..cbb528a39 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/automation-claude-assistant.yml @@ -1,4 +1,4 @@ -name: Claude Code (on mention - extended) +name: Automation - Claude Assistant on: issue_comment: @@ -21,39 +21,41 @@ jobs: # - Issue assigned (body) # - Manual dispatch if: > - ( - github.event_name == 'issue_comment' && - github.event.comment.body != null && - contains(toLower(github.event.comment.body), '@claude') - ) || - ( - github.event_name == 'pull_request_review_comment' && - github.event.comment.body != null && - contains(toLower(github.event.comment.body), '@claude') - ) || - ( - github.event_name == 'pull_request_review' && - github.event.review.body != null && - contains(toLower(github.event.review.body), '@claude') - ) || - ( - github.event_name == 'issues' && + secrets.CLAUDE_CODE_OAUTH_TOKEN != '' && ( ( + github.event_name == 'issue_comment' && + github.event.comment.body != null && + contains(toLower(github.event.comment.body), '@claude') + ) || + ( + github.event_name == 'pull_request_review_comment' && + github.event.comment.body != null && + contains(toLower(github.event.comment.body), '@claude') + ) || + ( + github.event_name == 'pull_request_review' && + github.event.review.body != null && + contains(toLower(github.event.review.body), '@claude') + ) || + ( + github.event_name == 'issues' && ( - github.event.action == 'opened' && ( - (github.event.issue.body != null && contains(toLower(github.event.issue.body), '@claude')) || - contains(toLower(github.event.issue.title), '@claude') + github.event.action == 'opened' && + ( + (github.event.issue.body != null && contains(toLower(github.event.issue.body), '@claude')) || + contains(toLower(github.event.issue.title), '@claude') + ) + ) || + ( + github.event.action == 'assigned' && + github.event.issue.body != null && + contains(toLower(github.event.issue.body), '@claude') ) - ) || - ( - github.event.action == 'assigned' && - github.event.issue.body != null && - contains(toLower(github.event.issue.body), '@claude') ) - ) - ) || - (github.event_name == 'workflow_dispatch') + ) || + (github.event_name == 'workflow_dispatch') + ) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/automation-claude-review.yml similarity index 67% rename from .github/workflows/claude-code-review.yml rename to .github/workflows/automation-claude-review.yml index 582537a43..8d065ba1c 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/automation-claude-review.yml @@ -1,4 +1,4 @@ -name: Claude Code Review (on mention - extended) +name: Automation - Claude Code Review on: issue_comment: @@ -17,23 +17,25 @@ jobs: # - Submitted PR review body containing @claude # - Manual dispatch if: > - ( - github.event_name == 'issue_comment' && - github.event.issue.pull_request && - github.event.comment.body != null && - contains(toLower(github.event.comment.body), '@claude') - ) || - ( - github.event_name == 'pull_request_review_comment' && - github.event.comment.body != null && - contains(toLower(github.event.comment.body), '@claude') - ) || - ( - github.event_name == 'pull_request_review' && - github.event.review.body != null && - contains(toLower(github.event.review.body), '@claude') - ) || - (github.event_name == 'workflow_dispatch') + secrets.CLAUDE_CODE_OAUTH_TOKEN != '' && ( + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + github.event.comment.body != null && + contains(toLower(github.event.comment.body), '@claude') + ) || + ( + github.event_name == 'pull_request_review_comment' && + github.event.comment.body != null && + contains(toLower(github.event.comment.body), '@claude') + ) || + ( + github.event_name == 'pull_request_review' && + github.event.review.body != null && + contains(toLower(github.event.review.body), '@claude') + ) || + (github.event_name == 'workflow_dispatch') + ) runs-on: ubuntu-latest permissions: contents: read diff --git a/.github/workflows/issue-title-labeler.yml b/.github/workflows/automation-issue-labeler.yml similarity index 97% rename from .github/workflows/issue-title-labeler.yml rename to .github/workflows/automation-issue-labeler.yml index a994ef4e7..d3fd82963 100644 --- a/.github/workflows/issue-title-labeler.yml +++ b/.github/workflows/automation-issue-labeler.yml @@ -1,4 +1,4 @@ -name: 🏷️ Issue Title Labeler +name: Automation - Issue Labeler on: issues: diff --git a/.github/workflows/labels-sync.yml b/.github/workflows/automation-label-sync.yml similarity index 93% rename from .github/workflows/labels-sync.yml rename to .github/workflows/automation-label-sync.yml index 925afc4a6..35f35d2e7 100644 --- a/.github/workflows/labels-sync.yml +++ b/.github/workflows/automation-label-sync.yml @@ -1,4 +1,4 @@ -name: 🔖 Sync Labels +name: Automation - Label Sync on: push: diff --git a/.github/workflows/pr-title-labeler.yml b/.github/workflows/automation-pr-labeler.yml similarity index 98% rename from .github/workflows/pr-title-labeler.yml rename to .github/workflows/automation-pr-labeler.yml index c88c89792..61b6e3758 100644 --- a/.github/workflows/pr-title-labeler.yml +++ b/.github/workflows/automation-pr-labeler.yml @@ -1,4 +1,4 @@ -name: 🏷️ PR Title Labeler +name: Automation - PR Labeler on: pull_request_target: diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/ci-backend-lint.yml similarity index 76% rename from .github/workflows/golangci-lint.yml rename to .github/workflows/ci-backend-lint.yml index 83eabaa34..a585194bc 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/ci-backend-lint.yml @@ -1,7 +1,7 @@ -# Go Linting with golangci-lint +# Go linting with golangci-lint # Runs golangci-lint on the backend directory # Separate from MegaLinter due to Go version requirements (1.25+) -name: 🔍 Go Lint +name: CI - Backend Lint on: push: @@ -10,15 +10,15 @@ on: - 'backend/**/*.go' - 'backend/go.mod' - 'backend/go.sum' - - 'backend/.golangci.yml' - - '.github/workflows/golangci-lint.yml' + - '.github/linters/.golangci.yml' + - '.github/workflows/ci-backend-lint.yml' pull_request: paths: - 'backend/**/*.go' - 'backend/go.mod' - 'backend/go.sum' - - 'backend/.golangci.yml' - - '.github/workflows/golangci-lint.yml' + - '.github/linters/.golangci.yml' + - '.github/workflows/ci-backend-lint.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -43,11 +43,11 @@ jobs: with: version: latest working-directory: backend - args: --config .golangci.yml --timeout 5m + args: --config ../.github/linters/.golangci.yml --timeout 5m - name: 📊 Report Results if: always() run: | echo "✅ Go linting completed" - echo "Configuration: backend/.golangci.yml" + echo "Configuration: .github/linters/.golangci.yml" echo "Go version: 1.25" diff --git a/.github/workflows/ci-code-coverage.yml b/.github/workflows/ci-code-coverage.yml new file mode 100644 index 000000000..7934dacd7 --- /dev/null +++ b/.github/workflows/ci-code-coverage.yml @@ -0,0 +1,105 @@ +name: CI - Code Coverage + +on: + pull_request: + push: + branches: + - main + - master + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + backend-coverage: + name: Backend Coverage + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: backend + + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Set up Go + uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 + with: + go-version-file: backend/go.mod + cache: true + + - name: Install dependencies + run: go mod download + + - name: Run coverage suite + run: make test-coverage + + - name: Capture coverage summary + run: | + TOTAL_LINE_COVERAGE=$(go tool cover -func=coverage.out | awk '/total:/ {print $3}') + echo "## Backend Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "- Line coverage: ${TOTAL_LINE_COVERAGE}" >> "$GITHUB_STEP_SUMMARY" + echo "- Threshold: 72%" >> "$GITHUB_STEP_SUMMARY" + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: | + backend/coverage.out + backend/coverage.html + retention-days: 14 + + frontend-coverage: + name: Frontend Coverage + runs-on: ubuntu-latest + defaults: + run: + shell: bash + working-directory: frontend + + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Set up Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + with: + node-version: 20 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Run coverage suite + run: pnpm run test:coverage + + - name: Capture coverage summary + run: | + SUMMARY_FILE="coverage/coverage-summary.json" + if [ -f "$SUMMARY_FILE" ]; then + LINES=$(jq -r '.total.lines.pct' "$SUMMARY_FILE") + STATEMENTS=$(jq -r '.total.statements.pct' "$SUMMARY_FILE") + echo "## Frontend Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "- Lines: ${LINES}%" >> "$GITHUB_STEP_SUMMARY" + echo "- Statements: ${STATEMENTS}%" >> "$GITHUB_STEP_SUMMARY" + else + echo "## Frontend Coverage" >> "$GITHUB_STEP_SUMMARY" + echo "- Coverage summary not found (expected at coverage/coverage-summary.json)" >> "$GITHUB_STEP_SUMMARY" + fi + + - name: Upload coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: | + frontend/coverage/** + retention-days: 14 diff --git a/.github/workflows/build-containers.yml b/.github/workflows/ci-container-build.yml similarity index 93% rename from .github/workflows/build-containers.yml rename to .github/workflows/ci-container-build.yml index bd5ebf57f..084a33f33 100644 --- a/.github/workflows/build-containers.yml +++ b/.github/workflows/ci-container-build.yml @@ -1,6 +1,5 @@ -# Build and Push Container Images -# Optimized for fast container builds without heavy test dependencies -name: 🐳 Build Containers +# Build and push container images to GHCR +name: CI - Container Build on: push: @@ -28,9 +27,6 @@ jobs: build-and-push: name: 🐳 Build & Push Images runs-on: ubuntu-latest - strategy: - matrix: - component: [backend, frontend] permissions: contents: read @@ -46,6 +42,9 @@ jobs: - name: Checkout code uses: actions/checkout@v5 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -57,7 +56,6 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Extract metadata (Backend) - if: matrix.component == 'backend' id: meta-backend uses: docker/metadata-action@v5 with: @@ -75,7 +73,6 @@ jobs: }} - name: Extract metadata (Frontend) - if: matrix.component == 'frontend' id: meta-frontend uses: docker/metadata-action@v5 with: @@ -93,7 +90,6 @@ jobs: }} - name: Build and push (Backend) - if: matrix.component == 'backend' id: build-backend uses: docker/build-push-action@v6 with: @@ -114,7 +110,6 @@ jobs: }} - name: Build and push (Frontend) - if: matrix.component == 'frontend' id: build-frontend uses: docker/build-push-action@v6 with: diff --git a/.github/workflows/ci-dependency-review.yml b/.github/workflows/ci-dependency-review.yml new file mode 100644 index 000000000..178dbe00d --- /dev/null +++ b/.github/workflows/ci-dependency-review.yml @@ -0,0 +1,26 @@ +name: CI - Dependency Review + +on: + pull_request: + types: + - opened + - reopened + - synchronize + +permissions: + contents: read + +jobs: + dependency-review: + name: Dependency Review + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + with: + fetch-depth: 2 + + - name: Review dependencies + uses: actions/dependency-review-action@v4 + with: + fail-on-severity: high diff --git a/.github/workflows/ci-frontend-lighthouse.yml b/.github/workflows/ci-frontend-lighthouse.yml new file mode 100644 index 000000000..2ef8b5c79 --- /dev/null +++ b/.github/workflows/ci-frontend-lighthouse.yml @@ -0,0 +1,132 @@ +name: CI - Frontend Lighthouse + +on: + push: + branches: [main, master] + pull_request: + branches: [main, master] + workflow_dispatch: + +env: + # Shared environment variables for all jobs + POSTGRES_USER: leaflock_ci + POSTGRES_PASSWORD: leaflock_ci_password_2024 + POSTGRES_DB: leaflock_ci + JWT_SECRET: CI_Jwt_secret_for_e2e_tests_1234567890 + SERVER_ENCRYPTION_KEY: CI_Encryption_key_for_e2e_tests_123456 + DEFAULT_ADMIN_PASSWORD: LeafLockAdmin#2024 + DEFAULT_ADMIN_EMAIL: admin+ci@leaflock.app + ENABLE_DEFAULT_ADMIN: "true" + ENABLE_REGISTRATION: "true" + SKIP_ADMIN_VALIDATION: "true" + CORS_ORIGINS: http://localhost:3000,http://127.0.0.1:3000 + VITE_API_URL: http://localhost:8080 + VITE_ENABLE_ADMIN_PANEL: "true" + +jobs: + setup: + name: Setup & Build + runs-on: ubuntu-latest + timeout-minutes: 15 + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Setup Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Start services with Docker Compose + run: docker compose up -d --wait + env: + VITE_API_URL: http://localhost:8080 + BACKEND_INTERNAL_URL: http://backend:8080 + + - name: Verify services are running + run: | + echo "Checking frontend health..." + curl -fsS http://localhost:3000/health || exit 1 + echo "Checking backend health..." + curl -fsS http://localhost:8080/api/v1/health || exit 1 + echo "✓ All services are healthy!" + + - name: Cleanup + if: always() + run: docker compose down -v + + lighthouse: + name: Run Lighthouse CI + runs-on: ubuntu-latest + needs: setup + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.18.3 + + - name: Setup Node.js + uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Install frontend dependencies + working-directory: frontend + run: pnpm install --frozen-lockfile + + - name: Verify system Chrome + run: | + CHROME_BIN="$(command -v google-chrome-stable || command -v google-chrome || command -v chromium-browser || true)" + if [ -z "$CHROME_BIN" ]; then + echo "Chrome/Chromium executable not found on PATH." + exit 1 + fi + echo "Using Chrome binary at: $CHROME_BIN" + "$CHROME_BIN" --version + + - name: Start services with Docker Compose + run: docker compose up -d --wait + env: + VITE_API_URL: http://localhost:8080 + BACKEND_INTERNAL_URL: http://backend:8080 + + - name: Run Lighthouse CI + working-directory: frontend + run: pnpm run lhci -- --config=lighthouserc.json + + - name: Upload Lighthouse report + if: always() + uses: actions/upload-artifact@v4 + with: + name: lighthouse-report + path: frontend/.lighthouseci + retention-days: 7 + + - name: Cleanup + if: always() + run: docker compose down -v diff --git a/.github/workflows/frontend-lint.yml b/.github/workflows/ci-frontend-quality.yml similarity index 86% rename from .github/workflows/frontend-lint.yml rename to .github/workflows/ci-frontend-quality.yml index bd65730e7..4406fbce1 100644 --- a/.github/workflows/frontend-lint.yml +++ b/.github/workflows/ci-frontend-quality.yml @@ -1,7 +1,6 @@ -# Frontend Linting with ESLint and Prettier -# Runs ESLint 9 and Prettier on the frontend directory -# Separate from MegaLinter due to ESLint version requirements (9.x) -name: 🎨 Frontend Lint +# Frontend linting suite (ESLint, Prettier, types) +# Separate from MegaLinter due to ESLint 9 flat config requirements +name: CI - Frontend Quality on: push: @@ -12,7 +11,7 @@ on: - 'frontend/pnpm-lock.yaml' - 'frontend/eslint.config.js' - 'frontend/.prettierrc' - - '.github/workflows/frontend-lint.yml' + - '.github/workflows/ci-frontend-quality.yml' pull_request: paths: - 'frontend/**/*.{js,jsx,ts,tsx}' @@ -20,7 +19,7 @@ on: - 'frontend/pnpm-lock.yaml' - 'frontend/eslint.config.js' - 'frontend/.prettierrc' - - '.github/workflows/frontend-lint.yml' + - '.github/workflows/ci-frontend-quality.yml' concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/.github/workflows/e2e-verify.yml b/.github/workflows/e2e-verify.yml deleted file mode 100644 index 35e658ce4..000000000 --- a/.github/workflows/e2e-verify.yml +++ /dev/null @@ -1,235 +0,0 @@ -name: E2E Verify - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - workflow_dispatch: - -jobs: - e2e: - name: E2E Tests with Playwright - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:18-alpine@sha256:f898ac406e1a9e05115cc2efcb3c3abb3a92a4c0263f3b6f6aaae354cbb1953a - env: - POSTGRES_PASSWORD: testpass123 - POSTGRES_DB: leaflock - POSTGRES_USER: postgres - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - --hostname postgres - ports: - - 5432:5432 - - redis: - image: redis:8-alpine - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - --hostname redis - ports: - - 6379:6379 - - env: - # Environment variables for services - POSTGRES_PASSWORD: testpass123 - REDIS_PASSWORD: redispass123 - JWT_SECRET: test_jwt_secret_for_ci_only_not_for_production_use_12345678 - SERVER_ENCRYPTION_KEY: test_encryption_key_32_chars_ok_1234 - CORS_ORIGINS: http://localhost:3000 - VITE_API_URL: http://localhost:8080 - VITE_ENABLE_ADMIN_PANEL: "true" - - # Act CLI compatibility - use service names when running in act - DATABASE_URL: >- - ${{ github.actor == 'act' && - 'postgres://postgres:testpass123@postgres:5432/leaflock?sslmode=disable' || - 'postgres://postgres:testpass123@localhost:5432/leaflock?sslmode=disable' }} - REDIS_URL: ${{ github.actor == 'act' && 'redis:6379' || 'localhost:6379' }} - - steps: - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - - - name: Setup Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 - with: - node-version: 22 - cache: 'pnpm' - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Setup Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 - with: - go-version: '1.25' - cache-dependency-path: backend/go.sum - - - name: Install PostgreSQL client - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - - name: Wait for Services - run: | - echo "Waiting for PostgreSQL..." - for i in {1..30}; do - if pg_isready -h localhost -p 5432 -U postgres; then - echo "PostgreSQL is ready!" - break - fi - echo "PostgreSQL is unavailable - sleeping" - sleep 2 - done - - echo "Waiting for Redis..." - for i in {1..30}; do - if timeout 2 bash -c " backend.pid - env: - PORT: 8080 - - - name: Start frontend - working-directory: frontend - run: | - pnpm run preview --port 3000 --host 0.0.0.0 & - echo $! > frontend.pid - - - name: Test database connectivity - run: | - echo "Testing database connectivity..." - for i in {1..30}; do - if pg_isready -h localhost -p 5432 -U postgres -d leaflock; then - echo "✅ Database is ready and accepting connections" - break - fi - echo "⏳ Database not ready, attempt $i/30..." - sleep 2 - done - - echo "Testing database access..." - PGPASSWORD=testpass123 psql -h localhost -p 5432 -U postgres -d leaflock -c "SELECT 1;" || { - echo "❌ Failed to connect to database" - exit 1 - } - - - name: Wait for application ready - run: | - echo "Waiting for backend..." - for i in {1..60}; do - if curl -fsS http://localhost:8080/api/v1/health >/dev/null 2>&1; then - echo "✅ Backend ready" - break - fi - echo "⏳ Backend not ready, attempt $i/60..." - sleep 2 - done - - echo "Testing backend API endpoints..." - curl -v http://localhost:8080/api/v1/health || { - echo "❌ Backend health check failed" - exit 1 - } - - echo "Waiting for frontend..." - for i in {1..60}; do - code=$(curl -s -o /dev/null -w "%{http_code}" \ - http://localhost:3000/ 2>/dev/null || echo "000") - if [ "$code" = "200" ]; then - echo "✅ Frontend ready" - break - fi - echo "⏳ Frontend not ready (code: $code), attempt $i/60..." - sleep 2 - done - - - name: Test IPv6 Support - run: | - echo "Testing IPv6 dual-stack support..." - - # Test backend IPv4 - echo "Testing backend on IPv4 (127.0.0.1)..." - if curl -fsS http://127.0.0.1:8080/api/v1/health/live 2>&1 | grep -q "live"; then - echo "✅ Backend IPv4 works" - else - echo "❌ Backend IPv4 failed" - exit 1 - fi - - # Test backend IPv6 (optional - may not be available in CI) - echo "Testing backend on IPv6 ([::1])..." - if curl -fsS "http://[::1]:8080/api/v1/health/live" 2>/dev/null | grep -q "live"; then - echo "✅ Backend IPv6 works" - else - echo "⚠️ Backend IPv6 not available in CI (expected on IPv4-only systems)" - fi - - echo "🔍 Final connectivity check..." - echo "Backend health: $(curl -s http://localhost:8080/api/v1/health || echo 'FAILED')" - echo "Frontend status: $(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ || echo 'FAILED')" - - - name: Install Playwright browsers - working-directory: frontend - run: pnpm exec playwright install --with-deps - - - name: Run Playwright tests - working-directory: frontend - run: pnpm exec playwright test --reporter=list --timeout=30000 - env: - BASE_URL: http://localhost:3000 - - - name: Upload Playwright report - uses: actions/upload-artifact@v4 - if: failure() - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 30 - - - name: Cleanup processes - if: always() - run: | - if [ -f backend/backend.pid ]; then - kill $(cat backend/backend.pid) || true - fi - if [ -f frontend/frontend.pid ]; then - kill $(cat frontend/frontend.pid) || true - fi - # Kill any remaining processes - pkill -f "./app" || true - pkill -f "vite preview" || true diff --git a/.github/workflows/frontend-playwright.yml b/.github/workflows/frontend-playwright.yml deleted file mode 100644 index 9344b921a..000000000 --- a/.github/workflows/frontend-playwright.yml +++ /dev/null @@ -1,227 +0,0 @@ -name: Frontend Playwright - -on: - push: - branches: [main, master] - pull_request: - branches: [main, master] - workflow_dispatch: - -jobs: - playwright: - name: Run Playwright UI tests - runs-on: ubuntu-latest - timeout-minutes: 30 - - steps: - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.18.3 - - - name: Setup Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Install dependencies - working-directory: frontend - run: pnpm install --frozen-lockfile - - - name: Install Playwright browsers - working-directory: frontend - run: pnpm exec playwright install --with-deps - - - name: Run Playwright tests - working-directory: frontend - run: pnpm exec playwright test --reporter=line - - - name: Upload Playwright report - if: failure() - uses: actions/upload-artifact@v4 - with: - name: playwright-report - path: frontend/playwright-report/ - retention-days: 7 - - lighthouse: - name: Lighthouse CI - runs-on: ubuntu-latest - needs: playwright - timeout-minutes: 30 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: testpass123 - POSTGRES_DB: leaflock - DATABASE_URL: postgres://postgres:testpass123@localhost:5432/leaflock?sslmode=disable - REDIS_URL: localhost:6379 - JWT_SECRET: test_jwt_secret_for_ci_only_not_for_production_use_12345678 - SERVER_ENCRYPTION_KEY: test_encryption_key_32_chars_ok_1234 - CORS_ORIGINS: http://localhost:3000 - VITE_API_URL: http://localhost:8080 - VITE_ENABLE_ADMIN_PANEL: "true" - - steps: - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - - name: Setup pnpm - uses: pnpm/action-setup@v4 - with: - version: 10.18.3 - - - name: Setup Node.js - uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 - with: - node-version: 22 - cache: pnpm - cache-dependency-path: frontend/pnpm-lock.yaml - - - name: Setup Go - uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6 - with: - go-version: '1.25' - cache-dependency-path: backend/go.sum - - - name: Setup Redis - uses: redis-actions/setup-redis@v1 - with: - redis-version: '7' - - - name: Setup PostgreSQL - uses: postgresql-actions/setup-postgresql@v1 - with: - postgresql-version: '16' - postgresql-db: ${{ env.POSTGRES_DB }} - postgresql-user: ${{ env.POSTGRES_USER }} - postgresql-password: ${{ env.POSTGRES_PASSWORD }} - - - name: Install PostgreSQL client - run: | - sudo apt-get update - sudo apt-get install -y postgresql-client - - - name: Wait for services - env: - PGPASSWORD: ${{ env.POSTGRES_PASSWORD }} - run: | - echo "Waiting for PostgreSQL..." - for i in {1..30}; do - if pg_isready -h localhost -p 5432 -U "${POSTGRES_USER}"; then - echo "PostgreSQL is ready!" - break - fi - echo "PostgreSQL is unavailable - sleeping" - sleep 2 - done - - echo "Waiting for Redis..." - for i in {1..30}; do - if timeout 2 bash -c " backend.pid - - - name: Start frontend - working-directory: frontend - env: - VITE_API_URL: ${{ env.VITE_API_URL }} - VITE_ENABLE_ADMIN_PANEL: ${{ env.VITE_ENABLE_ADMIN_PANEL }} - run: | - pnpm run preview --port 3000 --host 0.0.0.0 & - echo $! > frontend.pid - - - name: Wait for application readiness - run: | - echo "Waiting for backend..." - for i in {1..60}; do - if curl -fsS http://localhost:8080/api/v1/health >/dev/null 2>&1; then - echo "Backend ready" - break - fi - echo "Backend not ready, attempt $i/60..." - sleep 2 - done - - echo "Waiting for frontend..." - for i in {1..60}; do - code=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:3000/ || echo "000") - if [ "$code" = "200" ]; then - echo "Frontend ready" - break - fi - echo "Frontend not ready (code: $code), attempt $i/60..." - sleep 2 - done - - - name: Install Playwright Chromium for Lighthouse - working-directory: frontend - run: pnpm exec playwright install --with-deps chromium - - - name: Resolve Chromium path - id: chromium - working-directory: frontend - run: | - CHROMIUM_PATH=$(node --input-type=module -e "import('playwright-core').then(m => console.log(m.chromium.executablePath()))") - echo "executable=$CHROMIUM_PATH" >> "$GITHUB_OUTPUT" - - - name: Run Lighthouse CI - working-directory: frontend - env: - CHROME_PATH: ${{ steps.chromium.outputs.executable }} - LHCI_CHROME_PATH: ${{ steps.chromium.outputs.executable }} - run: pnpm run lhci -- --config=lighthouserc.json - - - name: Upload Lighthouse report - if: always() - uses: actions/upload-artifact@v4 - with: - name: lighthouse-report - path: frontend/.lighthouseci - retention-days: 7 - - - name: Cleanup processes - if: always() - run: | - if [ -f backend/backend.pid ]; then - kill $(cat backend/backend.pid) || true - fi - if [ -f frontend/frontend.pid ]; then - kill $(cat frontend/frontend.pid) || true - fi - pkill -f "./app" || true - pkill -f "vite preview" || true diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index 47379220b..98894f9b2 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -1,7 +1,7 @@ # MegaLinter GitHub Action configuration file # More info at https://megalinter.io --- -name: MegaLinter +name: CI - MegaLinter # Trigger mega-linter at every push. Action will also be visible from # Pull Requests to main diff --git a/.github/workflows/helm-release.yml b/.github/workflows/release-helm.yml similarity index 99% rename from .github/workflows/helm-release.yml rename to .github/workflows/release-helm.yml index e931a959c..b18817e52 100644 --- a/.github/workflows/helm-release.yml +++ b/.github/workflows/release-helm.yml @@ -1,4 +1,4 @@ -name: Helm Chart Release +name: Release - Helm Chart on: release: diff --git a/.github/workflows/release.yml b/.github/workflows/release-management.yml similarity index 97% rename from .github/workflows/release.yml rename to .github/workflows/release-management.yml index 77c131ffe..77e5348ef 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release-management.yml @@ -1,6 +1,6 @@ # Automated Release Management Workflow # Handles semantic versioning, changelog generation, and GitHub releases -name: 🚀 Release Management +name: Release - Management on: workflow_dispatch: @@ -205,6 +205,8 @@ jobs: - name: Setup pnpm uses: pnpm/action-setup@v4 + with: + version: 10.18.3 - name: Setup Node.js uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 # v6 @@ -332,7 +334,7 @@ jobs: - name: Container build notification run: | echo "📦 Container build workflow will be triggered automatically by the release event" - echo "🔗 Check build progress: https://github.com/${{ github.repository }}/actions/workflows/build-containers.yml" + echo "🔗 Check build progress: https://github.com/${{ github.repository }}/actions/workflows/ci-container-build.yml" VERSION="${{ needs.version-calculation.outputs.next_version }}" echo "📦 Containers will be available at:" @@ -340,7 +342,7 @@ jobs: echo " - ghcr.io/${{ github.repository }}/frontend:${VERSION}" echo "" - echo "The build-containers.yml workflow is configured to trigger on 'release: published' events" + echo "The ci-container-build.yml workflow is configured to trigger on 'release: published' events" echo "and will automatically build and push the container images for this release." # Post-release tasks @@ -403,7 +405,7 @@ jobs: echo "### 🔗 Links:" >> $GITHUB_STEP_SUMMARY echo "- [📋 View Release](https://github.com/${{ github.repository }}/releases/tag/${{ needs.version-calculation.outputs.next_version }})" >> $GITHUB_STEP_SUMMARY echo "- [📦 View Packages](https://github.com/${{ github.repository }}/pkgs/container)" >> $GITHUB_STEP_SUMMARY - echo "- [🔄 Build Workflow](https://github.com/${{ github.repository }}/actions/workflows/build-containers.yml)" >> $GITHUB_STEP_SUMMARY + echo "- [🔄 Build Workflow](https://github.com/${{ github.repository }}/actions/workflows/ci-container-build.yml)" >> $GITHUB_STEP_SUMMARY echo "- [🚄 Railway Deploy (Manual)](https://github.com/${{ github.repository }}/actions/workflows/railway-deploy.yml)" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### 🚀 Next Steps:" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 039c139e2..8b94820e1 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -1,9 +1,10 @@ -name: Unit Tests +name: CI - Unit Tests on: pull_request: push: branches: + - main - master permissions: diff --git a/.golangci.yml b/.golangci.yml index 60e61e0af..dcb7179fc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,41 +1,24 @@ -# golangci-lint configuration -# See: https://golangci-lint.run/usage/configuration/ +# Global golangci-lint configuration (mirrors .github/linters/.golangci.yml) +# Maintains compatibility for local runs without custom flags. -# This version is required for golangci-lint v2.x+ version: "2" run: timeout: 5m tests: true - # Use issues.exclude-dirs instead of skip-dirs (deprecated) - -issues: - # Exclude directories from linting - exclude-dirs: - - .gomod - - vendor - - node_modules - - frontend - exclude-files: - - ".*_test.go" - - ".*.pb.go" - exclude-use-default: false - max-issues-per-linter: 0 - max-same-issues: 0 linters: + default: none enable: + - errcheck - govet - - staticcheck - ineffassign + - staticcheck - unused - - errcheck - # Note: Only using core linters compatible with all golangci-lint versions -linters-settings: - govet: - check-shadowing: true - gofmt: - simplify: true - staticcheck: - checks: ["all"] +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + +severity: + default: error diff --git a/.sqlfluff b/.sqlfluff deleted file mode 100644 index 220e63e74..000000000 --- a/.sqlfluff +++ /dev/null @@ -1,46 +0,0 @@ -# SQLFluff Configuration -# See: https://docs.sqlfluff.com/en/stable/configuration.html - -[sqlfluff] -# Set dialect to PostgreSQL -dialect = postgres - -# Exclude directories -exclude_rules = L034,L031 - -# Templater configuration -templater = raw - -# File encoding -encoding = utf-8 - -[sqlfluff:indentation] -# Indentation rules -indented_joins = false -indented_using_on = true -template_blocks_indent = true - -[sqlfluff:layout:type:comma] -# Comma placement -spacing_before = touch -line_position = trailing - -[sqlfluff:rules] -# Allow longer lines for complex queries -max_line_length = 120 - -# Disable overly strict rules -# L034: Select wildcards (SELECT *) - common in migrations -# L031: Avoid aliases in FROM/JOIN - too strict for complex queries - -[sqlfluff:rules:L010] -# Capitalisation of keywords -capitalisation_policy = upper - -[sqlfluff:rules:L014] -# Unquoted identifiers -extended_capitalisation_policy = lower - -[sqlfluff:rules:L030] -# Function names -capitalisation_policy = upper diff --git a/AGENTS.md b/AGENTS.md index 7b38ce73d..2fbc268f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## Project Structure & Module Organization - Go backend in `backend/`; handlers in `handlers/`, domain logic in `services/`, helpers in `utils/`, realtime in `websocket/`, tests `_test.go` beside code. -- React/TypeScript frontend in `frontend/src`; UI in `components/`, stores in `stores/`, API helpers in `lib/`, e2e specs in `frontend/e2e`. pnpm is the supported package manager. +- React/TypeScript frontend in `frontend/src`; UI in `components/`, stores in `stores/`, API helpers in `lib/`. pnpm is the supported package manager. - Deployment tooling spans `docker-compose.yml`, `helm/`, and `leaflock-kube.yaml`. Docs live in `docs/`; automation in repo `scripts/` and service `scripts/`. ## Build, Test, and Development Commands @@ -21,7 +21,7 @@ ## Testing Guidelines - Backend unit tests live beside code (`*_test.go`); flag integration suites with `Integration` in the test name and start dependencies via `make test-db-up`. Run `make test-ci` before submitting PRs. - Keep coverage artifacts (`coverage.out`, `coverage.html`) in `backend/` and review them when touching auth, crypto, or storage flows. -- Frontend uses Vitest (`pnpm test`, `pnpm test:coverage`) and Playwright (`pnpm test:pw`); name specs `.test.tsx` and keep snapshots stable. +- Frontend uses Vitest (`pnpm test`, `pnpm test:coverage`); name specs `.test.tsx` and keep snapshots stable. ## Commit & Pull Request Guidelines - Favor Conventional Commit prefixes (`feat:`, `fix:`, `chore(deps):`) and scopes that mirror directories, e.g., `feat(frontend): add passkey modal`. diff --git a/CLAUDE.md b/CLAUDE.md index 1ade56fca..da9a8659d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ This file provides guidance to Claude Code when working with this repository. ## Project Overview Secure notes application with end-to-end encryption: -- **Backend**: Go 1.23+ with Fiber v2, PostgreSQL (pgx), Redis, JWT auth +- **Backend**: Go 1.25+ with Fiber v2, PostgreSQL (pgx), Redis, JWT auth - **Frontend**: React 18, TypeScript, Vite 5, Zustand, Quill 2.0 editor - **Encryption**: XChaCha20-Poly1305 (client-side), Argon2id (passwords) - **Infrastructure**: Podman/Docker, PostgreSQL 15, Redis 7 diff --git a/README.md b/README.md index 48cc88080..478cf83f5 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,16 @@ # LeafLock -[![CI/CD Pipeline](https://github.com/RelativeSure/LeafLock/actions/workflows/ci.yml/badge.svg)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci.yml) -[![Build Containers](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/build-containers.yml?branch=main&label=build%20containers)](https://github.com/RelativeSure/LeafLock/actions/workflows/build-containers.yml) -[![E2E Verify](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/e2e-verify.yml?branch=main&label=e2e%20verify)](https://github.com/RelativeSure/LeafLock/actions/workflows/e2e-verify.yml) +[![Unit Tests](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/unit-tests.yml?branch=main&label=unit%20tests)](https://github.com/RelativeSure/LeafLock/actions/workflows/unit-tests.yml) +[![Code Coverage](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-code-coverage.yml?branch=main&label=coverage)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-code-coverage.yml) +[![Backend Lint](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-backend-lint.yml?branch=main&label=backend%20lint)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-backend-lint.yml) +[![Frontend Quality](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-frontend-quality.yml?branch=main&label=frontend%20quality)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-frontend-quality.yml) +[![MegaLinter](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/mega-linter.yml?branch=main&label=mega%20linter)](https://github.com/RelativeSure/LeafLock/actions/workflows/mega-linter.yml) +[![Frontend Lighthouse](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-frontend-lighthouse.yml?branch=main&label=lighthouse)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-frontend-lighthouse.yml) +[![Container Build](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-container-build.yml?branch=main&label=container%20build)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-container-build.yml) +[![Dependency Review](https://img.shields.io/github/actions/workflow/status/RelativeSure/LeafLock/ci-dependency-review.yml?branch=main&label=dependency%20review)](https://github.com/RelativeSure/LeafLock/actions/workflows/ci-dependency-review.yml) [![Docs](https://img.shields.io/badge/docs-reference-blue)](./docs) [![Go Version](https://img.shields.io/badge/go-1.25-00ADD8?logo=go)](https://go.dev/dl/) [![pnpm](https://img.shields.io/badge/pnpm-10.x-ffd831?logo=pnpm)](https://pnpm.io/) -[![Coverage](https://img.shields.io/badge/coverage-72%25-brightgreen)](./backend) [![License: PolyForm Noncommercial](https://img.shields.io/badge/License-PolyForm_Noncommercial-blue.svg)](https://polyformproject.org/licenses/noncommercial/1.0.0) LeafLock is a privacy-first notes application with end-to-end encryption, real-time collaboration, and a Go backend. Everything can be self-hosted and kept under your control. diff --git a/backend/.golangci.yml b/backend/.golangci.yml deleted file mode 100644 index f1f051163..000000000 --- a/backend/.golangci.yml +++ /dev/null @@ -1,24 +0,0 @@ -# GolangCI-Lint configuration for Secure Notes backend -# Minimal v2 config for golangci-lint 2.5.0 / MegaLinter v9 compatibility - -version: "2" - -run: - timeout: 5m - tests: true - -linters: - default: none - enable: - - errcheck - - govet - - ineffassign - - staticcheck - - unused - -issues: - max-issues-per-linter: 0 - max-same-issues: 0 - -severity: - default: error diff --git a/backend/Makefile b/backend/Makefile index ce2b55ab7..0820755b5 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -84,7 +84,7 @@ fmt: ## Format Go code lint: ## Run linters @echo "$(GREEN)Running linters...$(NC)" @if command -v $(GOLINT) >/dev/null 2>&1; then \ - $(GOLINT) run ./...; \ + $(GOLINT) run --config ../.github/linters/.golangci.yml ./...; \ else \ echo "$(YELLOW)golangci-lint not found, skipping...$(NC)"; \ fi diff --git a/docs/src/content/docs/development/development-guide.mdx b/docs/src/content/docs/development/development-guide.mdx index 540a6d035..9d54a3fc1 100644 --- a/docs/src/content/docs/development/development-guide.mdx +++ b/docs/src/content/docs/development/development-guide.mdx @@ -116,7 +116,7 @@ pnpm --dir frontend run type-check`} language="bash" /> -CI executes the same targets inside `.github/workflows/e2e-verify.yml` before publishing container images. Keep the pipeline green and updates sail through 🚀. +CI executes the same targets via the unit-test, lint, coverage, and Lighthouse workflows before publishing container images. Keep the pipeline green and updates sail through 🚀. ## Further Reading diff --git a/docs/src/content/docs/getting-started/local-development.mdx b/docs/src/content/docs/getting-started/local-development.mdx index b00eecd4b..c10fb6340 100644 --- a/docs/src/content/docs/getting-started/local-development.mdx +++ b/docs/src/content/docs/getting-started/local-development.mdx @@ -161,8 +161,6 @@ pnpm test # All tests pnpm test ShareDialog.test # Specific pnpm test --coverage # With coverage`} language="bash" /> -**E2E CI**: `.github/workflows/e2e-verify.yml` - ## Common Tasks **Reset database**: diff --git a/docs/src/content/docs/guides/developer-guide.mdx b/docs/src/content/docs/guides/developer-guide.mdx index 6b6c92a70..c90a578c5 100644 --- a/docs/src/content/docs/guides/developer-guide.mdx +++ b/docs/src/content/docs/guides/developer-guide.mdx @@ -127,19 +127,17 @@ For single reference in HTML: ## Testing and CI/CD -### E2E Verification Workflow +### Lighthouse Performance Workflow -Located at `.github/workflows/e2e-verify.yml` +Located at `.github/workflows/ci-frontend-lighthouse.yml` **What it does:** -- Runs frontend lint, typecheck, tests -- Builds and starts Postgres, Redis, backend, frontend via `docker compose` -- Waits for readiness and tests API flow (register + list notes) -- Smoke tests frontend (index + asset) -- Runs backend tests with coverage gate -- Verifies Swagger access with `ADMIN_USER_IDS` fallback, then via RBAC grant - -**Admin panel in E2E:** +- Boots the stack with Docker Compose for realistic network and asset timing +- Runs the production frontend build +- Executes Lighthouse CI audits against the preview server +- Uploads reports as workflow artifacts for manual inspection + +**Admin panel in Lighthouse runs:** - Sets `VITE_ENABLE_ADMIN_PANEL=true` so the UI contains the Admin section ## Performance Optimization diff --git a/frontend/e2e/app.spec.ts b/frontend/e2e/app.spec.ts deleted file mode 100644 index 76d2d3c0d..000000000 --- a/frontend/e2e/app.spec.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { test, expect } from '@playwright/test' - -test.describe('Application', () => { - test('should load homepage successfully', async ({ page }) => { - await page.goto('/') - await expect(page).toHaveTitle(/LeafLock|Secure Notes/) - - // Check that the page loads without errors - const pageErrors: string[] = [] - page.on('pageerror', (error) => pageErrors.push(error.message)) - page.on('console', (msg) => { - if (msg.type() === 'error') { - pageErrors.push(msg.text()) - } - }) - - await page.waitForLoadState('networkidle') - - // Verify no critical errors occurred - const criticalErrors = pageErrors.filter( - (error) => - !error.includes('favicon') && !error.includes('404') && !error.includes('ResizeObserver') - ) - expect(criticalErrors).toEqual([]) - }) - - test('should have working navigation', async ({ page }) => { - await page.goto('/') - - // Check that the app loads with proper structure - await expect(page.locator('#root')).toBeVisible() - }) - - test('should handle network errors gracefully', async ({ page }) => { - // Block API calls to simulate network issues - await page.route('**/api/**', (route) => route.abort()) - - await page.goto('/') - - // App should still load even if API calls fail - await expect(page.locator('#root')).toBeVisible() - }) - - test('should be responsive on mobile devices', async ({ page }) => { - // Set mobile viewport - await page.setViewportSize({ width: 375, height: 667 }) - - await page.goto('/') - - // App should render properly on mobile - await expect(page.locator('#root')).toBeVisible() - - // Check that the app is usable on mobile - const emailInput = page.locator('input[type="email"]') - await expect(emailInput).toBeVisible() - }) - - test('should maintain accessibility standards', async ({ page }) => { - await page.goto('/') - - // Wait for page to fully load - await page.waitForLoadState('networkidle') - - // Check for basic accessibility elements - const emailInput = page.locator('input[type="email"]') - await expect(emailInput).toBeVisible() - - // Verify keyboard navigation works - await emailInput.click() // Focus first - await expect(emailInput).toBeFocused() - }) -}) diff --git a/frontend/e2e/auth.spec.ts b/frontend/e2e/auth.spec.ts deleted file mode 100644 index 206402ac8..000000000 --- a/frontend/e2e/auth.spec.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { test, expect } from './fixtures/auth-setup' -import { generateUniqueEmail } from './fixtures/test-data' - -test.describe('Authentication', () => { - test('should display login form on homepage', async ({ authPage }) => { - await authPage.goto() - await authPage.expectToBeOnAuthPage() - }) - - test('should register a new user successfully', async ({ authPage }) => { - const email = generateUniqueEmail() - const password = 'VerySecurePassword123!' - - await authPage.goto() - await authPage.register(email, password) - await authPage.expectToBeRedirectedToNotes() - }) - - test('should login existing user successfully', async ({ authPage }) => { - // First register a user - const email = generateUniqueEmail() - const password = 'VerySecurePassword123!' - - await authPage.goto() - await authPage.register(email, password) - await authPage.expectToBeRedirectedToNotes() - - // Logout (navigate to home to trigger logout in this simple test) - await authPage.goto() - - // Then login with same credentials - await authPage.login(email, password) - await authPage.expectToBeRedirectedToNotes() - }) - - test('should show error for invalid login', async ({ authPage, page }) => { - await authPage.goto() - await authPage.login('invalid@example.com', 'wrongpassword') - - // Expect to stay on auth page (not redirected) - await expect(page).toHaveURL('/') - await authPage.expectToBeOnAuthPage() - }) - - test('should validate password requirements', async ({ authPage, page }) => { - const email = generateUniqueEmail() - - await authPage.goto() - await authPage.toggleToRegisterLink.click() - - // Try with weak password - await authPage.emailInput.fill(email) - await authPage.passwordInput.fill('123') - await authPage.registerButton.click() - - // Should stay on registration page - await expect(page).toHaveURL('/') - }) - - test('should show password strength indicator during registration', async ({ - authPage, - page, - }) => { - const email = generateUniqueEmail() - - await authPage.goto() - await authPage.toggleToRegisterLink.click() - - await authPage.emailInput.fill(email) - - // Password strength indicator should be visible when registering - await authPage.passwordInput.fill('VerySecurePassword123!') - - // Check that we're still in registration mode (password strength visible) - await expect(page.locator('text=Use 12+ characters')).toBeVisible() - }) -}) diff --git a/frontend/e2e/fixtures/auth-setup.ts b/frontend/e2e/fixtures/auth-setup.ts deleted file mode 100644 index 1c78da5d6..000000000 --- a/frontend/e2e/fixtures/auth-setup.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { test as base, expect } from '@playwright/test' -import { AuthPage } from '../page-objects/auth.page' -import { NotesPage } from '../page-objects/notes.page' -import { generateUniqueEmail } from './test-data' - -type TestFixtures = { - authPage: AuthPage - notesPage: NotesPage - authenticatedUser: { email: string; password: string } -} - -export const test = base.extend({ - authPage: async ({ page }, use) => { - const authPage = new AuthPage(page) - await use(authPage) - }, - - notesPage: async ({ page }, use) => { - const notesPage = new NotesPage(page) - await use(notesPage) - }, - - authenticatedUser: async ({ authPage }, use) => { - const email = generateUniqueEmail() - const password = 'VerySecurePassword123!' - - await authPage.goto() - await authPage.register(email, password) - await authPage.expectToBeRedirectedToNotes() - - await use({ email, password }) - }, -}) - -export { expect } from '@playwright/test' diff --git a/frontend/e2e/fixtures/test-data.ts b/frontend/e2e/fixtures/test-data.ts deleted file mode 100644 index 8734fc43e..000000000 --- a/frontend/e2e/fixtures/test-data.ts +++ /dev/null @@ -1,29 +0,0 @@ -export const testUsers = { - user1: { - email: 'test1@example.com', - password: 'VerySecurePassword123!', - }, - user2: { - email: 'test2@example.com', - password: 'AnotherSecurePassword456!', - }, -} - -export const testNotes = { - simple: 'This is a simple test note', - markdown: '# Test Note\n\nThis is a **markdown** note with *formatting*.', - long: 'This is a very long note that contains a lot of text to test scrolling and rendering of large content. '.repeat( - 10 - ), - withSpecialChars: 'Test note with special characters: @#$%^&*()_+-=[]{}|;:,.<>?', -} - -export function generateUniqueEmail(): string { - const timestamp = Date.now() - return `test${timestamp}@example.com` -} - -export function generateTestNote(prefix = 'Test note'): string { - const timestamp = Date.now() - return `${prefix} created at ${timestamp}` -} diff --git a/frontend/e2e/import-export.spec.ts b/frontend/e2e/import-export.spec.ts deleted file mode 100644 index e9bcdf24b..000000000 --- a/frontend/e2e/import-export.spec.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { test, expect } from '@playwright/test' -import type { Route } from '@playwright/test' - -const PASSWORD = 'Playwright123!#' -const SALT_BYTES = Uint8Array.from([ - 12, 54, 98, 210, 45, 67, 189, 240, 121, 34, 200, 156, 43, 87, 165, 78, 11, 234, 156, 4, 88, 199, - 32, 58, 174, 201, 14, 65, 173, 250, 92, 39, -]) -const SALT_BASE64 = Buffer.from(SALT_BYTES).toString('base64') - -const createJwt = (payload: Record): string => { - const base64Url = (value: string) => - Buffer.from(value) - .toString('base64') - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_') - const header = base64Url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })) - const body = base64Url(JSON.stringify(payload)) - return `${header}.${body}.signature` -} - -const corsHeaders = { - 'access-control-allow-origin': '*', - 'access-control-allow-headers': 'Content-Type, Authorization, X-CSRF-Token', - 'access-control-allow-methods': 'GET,POST,OPTIONS', -} - -test.describe('Import/Export dialog', () => { - test('supports closing and export actions', async ({ page }) => { - await page.addInitScript( - ({ saltBytes }) => { - const readyMasterKey = new Uint8Array(32).fill(5) - const saltArray = Uint8Array.from(saltBytes) - const encodeContent = (value: unknown): string => JSON.stringify(value) - - ;(window as unknown as { __PLAYWRIGHT_CRYPTO_READY?: boolean }).__PLAYWRIGHT_CRYPTO_READY = - false - - import('/src/services/cryptoService.ts').then((module) => { - const { cryptoService } = module as { - cryptoService: { - masterKey: Uint8Array | null - initSodium: () => Promise - generateSalt: () => Promise - deriveKeyFromPassword: (password: string, salt: Uint8Array) => Promise - encryptData: (plaintext: string) => Promise - decryptData: (ciphertext: string) => Promise - setMasterKey: (key: Uint8Array) => Promise - } - } - - cryptoService.initSodium = async () => {} - cryptoService.generateSalt = async () => saltArray - cryptoService.deriveKeyFromPassword = async () => new Uint8Array(32).fill(9) - cryptoService.encryptData = async (plaintext: string) => encodeContent(plaintext) - cryptoService.decryptData = async (ciphertext: string) => { - try { - return JSON.parse(ciphertext) - } catch { - return typeof ciphertext === 'string' ? ciphertext : JSON.stringify(ciphertext) - } - } - cryptoService.setMasterKey = async (key: Uint8Array) => { - cryptoService.masterKey = key - } - cryptoService.masterKey = readyMasterKey - ;(window as unknown as { __PLAYWRIGHT_CRYPTO_READY?: boolean }).__PLAYWRIGHT_CRYPTO_READY = - true - }) - }, - { saltBytes: Array.from(SALT_BYTES) } - ) - - let nowIso = new Date().toISOString() - - const consoleWarnings: string[] = [] - page.on('console', (msg) => { - if (msg.type() === 'warning' || msg.type() === 'error') { - consoleWarnings.push(msg.text()) - } - }) - - await page.addInitScript(({ salt }) => { - window.localStorage.setItem('user_salt', salt) - window.localStorage.setItem('hasSeenOnboarding', 'true') - }, { salt: SALT_BASE64 }) - - const token = createJwt({ - user_id: 'user-123', - exp: Math.floor(Date.now() / 1000) + 3600, - }) - - const fulfillJson = (route: Route, body: unknown) => - route.fulfill({ - status: 200, - headers: { - ...corsHeaders, - 'content-type': 'application/json', - }, - body: JSON.stringify(body), - }) - - const fulfillOptions = (route: Route) => - route.fulfill({ - status: 200, - headers: corsHeaders, - }) - - await page.route('**/api/v1/auth/registration', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - return fulfillJson(route, { enabled: true }) - }) - - await page.route('**/api/v1/announcements', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - return fulfillJson(route, { announcements: [] }) - }) - - await page.route('**/api/v1/auth/login', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - const payload = route.request().postDataJSON() ?? {} - expect(payload.email).toBeTruthy() - expect(payload.password).toBeTruthy() - return fulfillJson(route, { - message: 'Login successful', - token, - user_id: 'user-123', - workspace_id: 'workspace-1', - }) - }) - - await page.route('**/api/v1/admin/health', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - return fulfillJson(route, { status: 'ok' }) - }) - - let lastNoteTimestamp = new Date().toISOString() - const notePlainTitle = 'Playwright Test Note' - const notePlainContent = 'This is a Playwright-generated note.' - const encryptedNote = { - id: 'note-1', - title_encrypted: JSON.stringify(notePlainTitle), - content_encrypted: JSON.stringify(JSON.stringify(notePlainContent)), - created_at: lastNoteTimestamp, - updated_at: lastNoteTimestamp, - } - - await page.addInitScript( - ({ exportResponse }) => { - const originalFetch = window.fetch.bind(window) - ;(window as any).__ACCOUNT_EXPORT_CALLED__ = false - window.fetch = async (...args: Parameters) => { - const input = args[0] - const url = typeof input === 'string' ? input : input.url - if (url.includes('/api/v1/account/export')) { - ;(window as any).__ACCOUNT_EXPORT_CALLED__ = true - try { - const response = await originalFetch(...args) - if (!response || !response.ok) { - throw new Error('fallback') - } - return response - } catch { - return new Response(JSON.stringify(exportResponse), { - status: 200, - headers: { 'Content-Type': 'application/json' }, - }) - } - } - return originalFetch(...args) - } - }, - { - exportResponse: { - version: '1.0', - exported_at: new Date().toISOString(), - user: { email: 'tester@example.com', created_at: new Date().toISOString() }, - notes: [ - { - id: 'note-1', - title: notePlainTitle, - content: notePlainContent, - }, - ], - }, - } - ) - - await page.route('**/api/v1/notes', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - if (route.request().method() === 'GET') { - lastNoteTimestamp = new Date().toISOString() - return fulfillJson(route, { - notes: [{ ...encryptedNote, created_at: lastNoteTimestamp, updated_at: lastNoteTimestamp }], - }) - } - return fulfillJson(route, { success: true }) - }) - - const storageInfo = { - storage_used: 25_600, - storage_limit: 5 * 1024 * 1024, - storage_remaining: 5 * 1024 * 1024 - 25_600, - usage_percentage: (25_600 / (5 * 1024 * 1024)) * 100, - } - await page.route('**/api/v1/user/storage', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - return fulfillJson(route, storageInfo) - }) - - let noteExportCalled = false - await page.route('**/api/v1/notes/note-1/export', (route) => { - if (route.request().method() === 'OPTIONS') return fulfillOptions(route) - const body = route.request().postDataJSON() - expect(body).toEqual({ format: 'markdown' }) - noteExportCalled = true - return fulfillJson(route, { - content: '# Exported Content', - filename: 'note-1.md', - format: 'markdown', - }) - }) - - await page.goto('/') - - await page.waitForFunction(() => !!(window as any).__PLAYWRIGHT_CRYPTO_READY) - - await page.getByLabel('Email').fill('tester@example.com') - await page.locator('input[name="password"]').fill(PASSWORD) - await page.getByRole('button', { name: 'Login' }).click() - - const importExportTrigger = page.getByRole('button', { name: 'Import/Export' }) - await expect(importExportTrigger).toBeVisible() - - await expect( - page.locator('[data-note-button]').filter({ hasText: notePlainTitle }).first() - ).toContainText(notePlainTitle) - - await importExportTrigger.click() - - const dialog = page.getByRole('dialog', { name: 'Import & Export Notes' }) - await expect(dialog).toBeVisible() - - await dialog - .getByRole('button', { name: 'Close' }) - .first() - .evaluate((button) => (button as HTMLButtonElement).click()) - await expect(dialog).toBeHidden() - - await importExportTrigger.click() - await expect(dialog).toBeVisible() - - const noteExportAlert = page.waitForEvent('dialog') - const dialogExportButton = dialog.getByRole('button', { name: /Export as/i }) - await Promise.all([ - page.waitForRequest('**/api/v1/notes/note-1/export'), - dialogExportButton.click(), - ]) - const exportDialog = await noteExportAlert - expect(exportDialog.message()).toContain('Export successful') - await exportDialog.accept() - expect(noteExportCalled).toBe(true) - - const accountExportAlert = page.waitForEvent('dialog') - const accountExportButton = dialog.getByRole('button', { name: 'Export Everything' }) - await accountExportButton.scrollIntoViewIfNeeded() - await accountExportButton.evaluate((button) => (button as HTMLButtonElement).click()) - const accountDialog = await accountExportAlert - expect(accountDialog.message()).toContain('Your full data export has started downloading') - await accountDialog.accept() - const accountExportFlag = await page.evaluate( - () => Boolean((window as any).__ACCOUNT_EXPORT_CALLED__) - ) - expect(accountExportFlag).toBe(true) - expect(consoleWarnings.some((msg) => msg.includes('Missing Description'))).toBe(false) - }) -}) diff --git a/frontend/e2e/notes.spec.ts b/frontend/e2e/notes.spec.ts deleted file mode 100644 index f6718b9de..000000000 --- a/frontend/e2e/notes.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { test, expect } from './fixtures/auth-setup' -import { testNotes, generateTestNote } from './fixtures/test-data' - -test.describe('Notes Management', () => { - test('should create and save a new note', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - await notesPage.expectToBeOnNotesPage() - - const noteContent = generateTestNote('My first note') - await notesPage.createNewNote() - await notesPage.writeNote(noteContent) - await notesPage.saveDraft() - - // Note should appear in the list - await notesPage.expectNoteInList(noteContent) - }) - - test('should edit an existing note', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - // Create initial note - const originalContent = generateTestNote('Original note') - await notesPage.createNewNote() - await notesPage.writeNote(originalContent) - await notesPage.saveDraft() - - // Edit the note - await notesPage.openNote(originalContent) - const updatedContent = originalContent + ' - UPDATED' - await notesPage.writeNote(updatedContent) - await notesPage.saveDraft() - - // Updated note should appear in list - await notesPage.expectNoteInList(updatedContent) - }) - - test('should delete a note', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - // Create a note to delete - const noteContent = generateTestNote('Note to delete') - await notesPage.createNewNote() - await notesPage.writeNote(noteContent) - await notesPage.saveDraft() - - // Delete the note - await notesPage.openNote(noteContent) - await notesPage.deleteNote() - - // Note should not appear in list anymore - await notesPage.expectNoteNotInList(noteContent) - }) - - test('should search notes', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - // Create multiple notes - const note1 = generateTestNote('Searchable note about cats') - const note2 = generateTestNote('Another note about dogs') - const note3 = generateTestNote('Final note about cats and dogs') - - for (const noteContent of [note1, note2, note3]) { - await notesPage.createNewNote() - await notesPage.writeNote(noteContent) - await notesPage.saveDraft() - } - - // Search for 'cats' - await notesPage.searchNotes('cats') - await notesPage.expectNoteInList(note1) - await notesPage.expectNoteNotInList(note2) // Only dogs - await notesPage.expectNoteInList(note3) - - // Clear search - await notesPage.searchNotes('') - await notesPage.expectNoteInList(note1) - await notesPage.expectNoteInList(note2) - await notesPage.expectNoteInList(note3) - }) - - test('should handle markdown formatting', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - await notesPage.createNewNote() - await notesPage.writeNote(testNotes.markdown) - await notesPage.saveDraft() - - // Verify the markdown note is saved - await notesPage.expectNoteInList('Test Note') - }) - - test('should handle special characters', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - await notesPage.createNewNote() - await notesPage.writeNote(testNotes.withSpecialChars) - await notesPage.saveDraft() - - // Verify the note with special characters is saved - await notesPage.expectNoteInList(testNotes.withSpecialChars) - }) - - test('should handle long notes', async ({ notesPage, authenticatedUser }) => { - await notesPage.goto() - - await notesPage.createNewNote() - await notesPage.writeNote(testNotes.long) - await notesPage.saveDraft() - - // Verify the long note is saved (check for a portion of it) - await notesPage.expectNoteInList('This is a very long note') - }) - - test('should maintain encryption for saved notes', async ({ - notesPage, - authenticatedUser, - page, - }) => { - await notesPage.goto() - - const secretContent = 'This is super secret information that should be encrypted' - await notesPage.createNewNote() - await notesPage.writeNote(secretContent) - await notesPage.saveDraft() - - // Check that the note appears in the UI (client-side decryption works) - await notesPage.expectNoteInList(secretContent) - - // Verify note is saved by refreshing page - await page.reload() - await notesPage.expectToBeOnNotesPage() - await notesPage.expectNoteInList(secretContent) - }) -}) diff --git a/frontend/e2e/page-objects/auth.page.ts b/frontend/e2e/page-objects/auth.page.ts deleted file mode 100644 index 4b5b05061..000000000 --- a/frontend/e2e/page-objects/auth.page.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { expect, type Locator, type Page } from '@playwright/test' - -export class AuthPage { - readonly page: Page - readonly emailInput: Locator - readonly passwordInput: Locator - readonly loginButton: Locator - readonly registerButton: Locator - readonly toggleToRegisterLink: Locator - readonly toggleToLoginLink: Locator - - constructor(page: Page) { - this.page = page - this.emailInput = page.locator('input[type="email"]') - this.passwordInput = page.locator('input[type="password"]') - this.loginButton = page.getByRole('button', { name: 'Login' }) - this.registerButton = page.getByRole('button', { name: 'Create Account' }) - this.toggleToRegisterLink = page.getByRole('button', { name: 'Need an account? Register' }) - this.toggleToLoginLink = page.getByRole('button', { name: 'Already have an account? Login' }) - } - - async goto() { - await this.page.goto('/') - } - - async login(email: string, password: string) { - await this.emailInput.fill(email) - await this.passwordInput.fill(password) - - // Wait for the login button to be ready and click it - await expect(this.loginButton).toBeEnabled() - await this.loginButton.click() - - // Wait for any loading state to complete - await expect(this.loginButton).not.toHaveText('Processing...') - } - - async register(email: string, password: string) { - await this.toggleToRegisterLink.click() - await this.emailInput.fill(email) - await this.passwordInput.fill(password) - - // Wait for the register button to be ready and click it - await expect(this.registerButton).toBeEnabled() - await this.registerButton.click() - - // Wait for any loading state to complete - await expect(this.registerButton).not.toHaveText('Processing...') - } - - async expectToBeOnAuthPage() { - await expect(this.emailInput).toBeVisible() - await expect(this.passwordInput).toBeVisible() - } - - async expectToBeRedirectedToNotes() { - // Wait for the notes view to appear (app uses state-based routing, not URL routing) - await expect(this.page.getByRole('button', { name: 'New Note' })).toBeVisible({ - timeout: 10000, - }) - // Additional verification that we're in the notes interface - await expect( - this.page - .locator('[data-testid="notes-list"]') - .or(this.page.getByText('No notes yet')) - .or(this.page.getByText('No notes found')) - ).toBeVisible() - } -} diff --git a/frontend/e2e/page-objects/notes.page.ts b/frontend/e2e/page-objects/notes.page.ts deleted file mode 100644 index 70aabec3d..000000000 --- a/frontend/e2e/page-objects/notes.page.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { expect, type Locator, type Page } from '@playwright/test' - -export class NotesPage { - readonly page: Page - readonly newNoteButton: Locator - readonly notesList: Locator - readonly searchInput: Locator - readonly noteEditor: Locator - readonly saveDraftButton: Locator - readonly publishButton: Locator - readonly deleteButton: Locator - readonly logoutButton: Locator - - constructor(page: Page) { - this.page = page - this.newNoteButton = page.getByRole('button', { name: /new note/i }) - this.notesList = page.locator('[data-testid="notes-list"]') - this.searchInput = page.locator('input[placeholder*="search" i]') - this.noteEditor = page.locator('.ProseMirror') - this.saveDraftButton = page.getByRole('button', { name: /save.*draft/i }) - this.publishButton = page.getByRole('button', { name: /publish/i }) - this.deleteButton = page.getByRole('button', { name: /delete/i }) - this.logoutButton = page.getByRole('button', { name: /logout/i }) - } - - async goto() { - // App uses state-based routing - we should already be in notes view after auth - await this.expectToBeOnNotesPage() - } - - async expectToBeOnNotesPage() { - // Check for notes interface elements instead of URL - await expect(this.newNoteButton).toBeVisible() - await expect( - this.notesList - .or(this.page.getByText('No notes yet')) - .or(this.page.getByText('No notes found')) - ).toBeVisible() - } - - async createNewNote() { - await this.newNoteButton.click() - await expect(this.noteEditor).toBeVisible() - } - - async writeNote(content: string) { - await this.noteEditor.click() - await this.noteEditor.fill(content) - } - - async saveDraft() { - await this.saveDraftButton.click() - } - - async publishNote() { - await this.publishButton.click() - } - - async deleteNote() { - await this.deleteButton.click() - // Confirm deletion if there's a confirmation dialog - const confirmButton = this.page.getByRole('button', { name: /confirm|delete|yes/i }) - if (await confirmButton.isVisible({ timeout: 1000 })) { - await confirmButton.click() - } - } - - async searchNotes(query: string) { - await this.searchInput.fill(query) - await this.page.waitForTimeout(500) // Wait for search debounce - } - - async expectNoteInList(content: string) { - await expect(this.notesList.getByText(content, { exact: false })).toBeVisible() - } - - async expectNoteNotInList(content: string) { - await expect(this.notesList.getByText(content, { exact: false })).not.toBeVisible() - } - - async openNote(content: string) { - await this.notesList.getByText(content, { exact: false }).click() - await expect(this.noteEditor).toBeVisible() - } - - async logout() { - await this.logoutButton.click() - } -} diff --git a/frontend/package.json b/frontend/package.json index 9444143ec..9242ec75a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,7 +19,6 @@ "test:watch": "vitest", "test:coverage": "vitest run --coverage", "test:ui": "vitest --ui", - "test:pw": "playwright test", "test:security": "vitest run src/**/*.test.js --reporter=verbose", "test:e2e": "vitest run src/e2e.test.js", "clean": "rm -rf dist node_modules/.vite coverage", @@ -82,7 +81,6 @@ "devDependencies": { "@eslint/js": "9.37.0", "@lhci/cli": "0.15.1", - "@playwright/test": "1.56.1", "@tailwindcss/postcss": "4.1.14", "@testing-library/jest-dom": "6.9.1", "@testing-library/react": "16.3.0", diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts deleted file mode 100644 index e396929ed..000000000 --- a/frontend/playwright.config.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { defineConfig, devices } from '@playwright/test' - -const port = process.env.PORT ?? '3000' -const host = process.env.HOST ?? '127.0.0.1' -const baseURL = process.env.BASE_URL ?? `http://${host}:${port}` - -export default defineConfig({ - testDir: './e2e', - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - timeout: 30_000, - - reporter: process.env.CI ? 'github' : 'list', - - use: { - baseURL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - - projects: [ - { - name: 'chromium', - use: { ...devices['Desktop Chrome'] }, - }, - ], - - webServer: { - command: `pnpm run dev -- --host ${host} --port ${port}`, - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, -}) diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 6a0a02f11..ab7846161 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -79,7 +79,7 @@ importers: version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@tanstack/react-query': specifier: ^5.89.0 - version: 5.90.5(react@19.2.0) + version: 5.90.3(react@19.2.0) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -109,7 +109,7 @@ importers: version: 0.546.0(react@19.2.0) marked: specifier: ^16.3.0 - version: 16.4.1 + version: 16.4.0 next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -162,9 +162,6 @@ importers: '@lhci/cli': specifier: 0.15.1 version: 0.15.1 - '@playwright/test': - specifier: 1.56.1 - version: 1.56.1 '@tailwindcss/postcss': specifier: 4.1.14 version: 4.1.14 @@ -785,11 +782,6 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@playwright/test@1.56.1': - resolution: {integrity: sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==} - engines: {node: '>=18'} - hasBin: true - '@preact/signals-core@1.12.1': resolution: {integrity: sha512-BwbTXpj+9QutoZLQvbttRg5x3l5468qaV2kufh+51yha1c53ep5dY4kTuZR35+3pAZxpfQerGJiQqg34ZNZ6uA==} @@ -1487,11 +1479,11 @@ packages: '@tailwindcss/postcss@4.1.14': resolution: {integrity: sha512-BdMjIxy7HUNThK87C7BC8I1rE8BVUsfNQSI5siQ4JK3iIa3w0XyVvVL9SXLWO//CtYTcp1v7zci0fYwJOjB+Zg==} - '@tanstack/query-core@5.90.5': - resolution: {integrity: sha512-wLamYp7FaDq6ZnNehypKI5fNvxHPfTYylE0m/ZpuuzJfJqhR5Pxg9gvGBHZx4n7J+V5Rg5mZxHHTlv25Zt5u+w==} + '@tanstack/query-core@5.90.3': + resolution: {integrity: sha512-HtPOnCwmx4dd35PfXU8jjkhwYrsHfuqgC8RCJIwWglmhIUIlzPP0ZcEkDAc+UtAWCiLm7T8rxeEfHZlz3hYMCA==} - '@tanstack/react-query@5.90.5': - resolution: {integrity: sha512-pN+8UWpxZkEJ/Rnnj2v2Sxpx1WFlaa9L6a4UO89p6tTQbeo+m0MS8oYDjbggrR8QcTyjKoYWKS3xJQGr3ExT8Q==} + '@tanstack/react-query@5.90.3': + resolution: {integrity: sha512-i/LRL6DtuhG6bjGzavIMIVuKKPWx2AnEBIsBfuMm3YoHne0a20nWmsatOCBcVSaT0/8/5YFjNkebHAPLVUSi0Q==} peerDependencies: react: ^18 || ^19 @@ -2629,11 +2621,6 @@ packages: fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} - fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3303,8 +3290,8 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - marked@16.4.1: - resolution: {integrity: sha512-ntROs7RaN3EvWfy3EZi14H4YxmT6A5YvywfhO+0pm+cH/dnSQRmdAmoFIc3B9aiwTehyk7pESH4ofyBY+V5hZg==} + marked@16.4.0: + resolution: {integrity: sha512-CTPAcRBq57cn3R8n3hwc2REddc28hjR7RzDXQ+lXLmMJYqn20BaI2cGw6QjgZGIgVfp2Wdfw4aMzgNteQ6qJgQ==} engines: {node: '>= 20'} hasBin: true @@ -3766,16 +3753,6 @@ packages: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} - playwright-core@1.56.1: - resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} - engines: {node: '>=18'} - hasBin: true - - playwright@1.56.1: - resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} - engines: {node: '>=18'} - hasBin: true - possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -5552,10 +5529,6 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@playwright/test@1.56.1': - dependencies: - playwright: 1.56.1 - '@preact/signals-core@1.12.1': {} '@puppeteer/browsers@2.10.12': @@ -6216,11 +6189,11 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.14 - '@tanstack/query-core@5.90.5': {} + '@tanstack/query-core@5.90.3': {} - '@tanstack/react-query@5.90.5(react@19.2.0)': + '@tanstack/react-query@5.90.3(react@19.2.0)': dependencies: - '@tanstack/query-core': 5.90.5 + '@tanstack/query-core': 5.90.3 react: 19.2.0 '@tanstack/react-table@8.21.3(react-dom@19.2.0(react@19.2.0))(react@19.2.0)': @@ -7671,9 +7644,6 @@ snapshots: fs.realpath@1.0.0: {} - fsevents@2.3.2: - optional: true - fsevents@2.3.3: optional: true @@ -8409,7 +8379,7 @@ snapshots: markdown-table@3.0.4: {} - marked@16.4.1: {} + marked@16.4.0: {} marky@1.3.0: {} @@ -9083,14 +9053,6 @@ snapshots: dependencies: find-up: 5.0.0 - playwright-core@1.56.1: {} - - playwright@1.56.1: - dependencies: - playwright-core: 1.56.1 - optionalDependencies: - fsevents: 2.3.2 - possible-typed-array-names@1.1.0: {} postcss-value-parser@4.2.0: {} diff --git a/frontend/src/components/ImportExportDialog.tsx b/frontend/src/components/ImportExportDialog.tsx index 593b54f0f..0017962e2 100644 --- a/frontend/src/components/ImportExportDialog.tsx +++ b/frontend/src/components/ImportExportDialog.tsx @@ -375,7 +375,8 @@ export function ImportExportDialog({ if (!response.ok) { const error = await response.json().catch(() => null) const message = - (error && (error.error || error.message)) || `Export failed with status ${response.status}` + (error && (error.error || error.message)) || + `Export failed with status ${response.status}` throw new Error(message) } diff --git a/frontend/src/features/app/hooks/useNotes.tsx b/frontend/src/features/app/hooks/useNotes.tsx index e31106ccb..0e1afbafc 100644 --- a/frontend/src/features/app/hooks/useNotes.tsx +++ b/frontend/src/features/app/hooks/useNotes.tsx @@ -134,10 +134,10 @@ export const useNotes = (api: SecureAPI, onLogout: () => void) => { }, []) useEffect(() => { - if (notes.length > 0 && !selectedNote) { + if (notes.length > 0 && !selectedNote && !loading) { setSelectedNote(notes[0]) } - }, [notes, selectedNote]) + }, [notes.length, selectedNote, loading]) // Use notes.length instead of notes array to prevent re-renders return { notes, diff --git a/frontend/src/features/notes/components/NotesEditor.tsx b/frontend/src/features/notes/components/NotesEditor.tsx index 6b3bace38..bedd77ee7 100644 --- a/frontend/src/features/notes/components/NotesEditor.tsx +++ b/frontend/src/features/notes/components/NotesEditor.tsx @@ -200,9 +200,7 @@ export const NotesEditor: React.FC = ({ setIsFullscreen((prev) => !prev) }, []) - const editorHeightClass = isFullscreen - ? 'min-h-[calc(100vh-11rem)]' - : 'min-h-[calc(100vh-14rem)]' + const editorHeightClass = isFullscreen ? 'min-h-[calc(100vh-11rem)]' : 'min-h-[calc(100vh-14rem)]' const editorContent = (
= ({

{note.content || 'No content'}

-

+

{viewingTrash ? 'Deleted' : 'Modified'}{' '} {new Date(note.updated_at).toLocaleDateString()}

-
+
{viewingTrash ? ( <>