diff --git a/.github/workflows/backend-cd.yml b/.github/workflows/backend-cd.yml index 9a607fc..ac04719 100644 --- a/.github/workflows/backend-cd.yml +++ b/.github/workflows/backend-cd.yml @@ -2,7 +2,7 @@ name: Deploy Backend on: push: - branches: [staging, main] + branches: [staging] paths: - "packages/fastapi/**" - ".github/workflows/backend-cd.yml" @@ -18,17 +18,6 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Determine environment - id: env - run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "deploy_path=/opt/harness-api" >> "$GITHUB_OUTPUT" - echo "service=harness-api" >> "$GITHUB_OUTPUT" - else - echo "deploy_path=/opt/harness-api-staging" >> "$GITHUB_OUTPUT" - echo "service=harness-api-staging" >> "$GITHUB_OUTPUT" - fi - - name: Set up SSH run: | mkdir -p ~/.ssh @@ -44,15 +33,15 @@ jobs: --exclude='__pycache__' \ --exclude='.git' \ -e "ssh -i ~/.ssh/harness.pem" \ - packages/fastapi/ ec2-user@52.45.218.243:${{ steps.env.outputs.deploy_path }}/ + packages/fastapi/ ec2-user@52.45.218.243:/opt/harness-api-staging/ - name: Install dependencies and restart run: | ssh -i ~/.ssh/harness.pem ec2-user@52.45.218.243 << EOF - cd ${{ steps.env.outputs.deploy_path }} + cd /opt/harness-api-staging python3.11 -m venv .venv 2>/dev/null || true .venv/bin/pip install -q -r requirements.txt - sudo systemctl restart ${{ steps.env.outputs.service }} + sudo systemctl restart harness-api-staging sleep 2 - sudo systemctl is-active ${{ steps.env.outputs.service }} + sudo systemctl is-active harness-api-staging EOF diff --git a/.github/workflows/frontend-cd.yml b/.github/workflows/frontend-cd.yml index 5c960f4..8c6e5a3 100644 --- a/.github/workflows/frontend-cd.yml +++ b/.github/workflows/frontend-cd.yml @@ -2,7 +2,7 @@ name: Deploy Frontend on: push: - branches: [staging, main] + branches: [staging] paths: - "apps/web/**" - "packages/convex-backend/**" @@ -15,7 +15,7 @@ concurrency: jobs: deploy: runs-on: ubuntu-latest - environment: ${{ github.ref == 'refs/heads/main' && 'production' || 'staging' }} + environment: staging steps: - name: Checkout code uses: actions/checkout@v4 @@ -31,33 +31,26 @@ jobs: env: CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }} - - name: Determine environment - id: env - run: | - if [ "${{ github.ref }}" = "refs/heads/main" ]; then - echo "env_name=production" >> "$GITHUB_OUTPUT" - echo "fastapi_url=${{ secrets.FASTAPI_URL_PROD }}" >> "$GITHUB_OUTPUT" - else - echo "env_name=staging" >> "$GITHUB_OUTPUT" - echo "fastapi_url=${{ secrets.FASTAPI_URL_STAGING }}" >> "$GITHUB_OUTPUT" - fi - - name: Build frontend run: cd apps/web && bun run build env: VITE_CONVEX_URL: ${{ secrets.VITE_CONVEX_URL }} VITE_CLERK_PUBLISHABLE_KEY: ${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }} CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }} - VITE_FASTAPI_URL: ${{ steps.env.outputs.fastapi_url }} + VITE_FASTAPI_URL: ${{ secrets.FASTAPI_URL_STAGING }} + ARCJET_KEY: ${{ secrets.ARCJET_KEY }} + + - name: Set Cloudflare Worker secrets + run: | + echo "${{ secrets.ARCJET_KEY }}" | cd apps/web && npx wrangler secret put ARCJET_KEY --name harness-web-staging + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} # The @cloudflare/vite-plugin flattens only top-level vars into the # generated dist/server/wrangler.json and drops env.* blocks, so # `--env` alone picks the right Worker name but leaves runtime vars # pointing at the top-level defaults. Override at deploy time. - name: Deploy to Cloudflare Workers - run: | - cd apps/web && npx wrangler deploy --env ${{ steps.env.outputs.env_name }} \ - --var VITE_CONVEX_URL:"${{ secrets.VITE_CONVEX_URL }}" \ - --var VITE_CLERK_PUBLISHABLE_KEY:"${{ secrets.VITE_CLERK_PUBLISHABLE_KEY }}" + run: cd apps/web && npx wrangler deploy --name harness-web-staging env: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..fd9cef9 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,109 @@ +name: Tests + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + +concurrency: + group: tests-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + frontend: + name: Frontend (vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Cache Bun install + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests with coverage + working-directory: apps/web + run: bun run test:coverage + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: frontend-coverage + path: apps/web/coverage + retention-days: 14 + + convex: + name: Convex (vitest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Cache Bun install + uses: actions/cache@v4 + with: + path: ~/.bun/install/cache + key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} + restore-keys: | + ${{ runner.os }}-bun- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run tests with coverage + working-directory: packages/convex-backend + run: bun run test:coverage + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: convex-coverage + path: packages/convex-backend/coverage + retention-days: 14 + + fastapi: + name: FastAPI (pytest) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: packages/fastapi/requirements-dev.txt + + - name: Install dependencies + working-directory: packages/fastapi + run: | + python -m pip install --upgrade pip + pip install -r requirements-dev.txt + + - name: Run tests with coverage + working-directory: packages/fastapi + run: pytest --cov --cov-report=term --cov-report=xml --cov-report=html + + - name: Upload coverage artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: fastapi-coverage + path: | + packages/fastapi/htmlcov + packages/fastapi/coverage.xml + retention-days: 14 diff --git a/apps/web/.env.example b/apps/web/.env.example index 4556958..62028eb 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -9,3 +9,6 @@ VITE_CONVEX_URL=https://your-project.convex.cloud # FastAPI backend URL VITE_FASTAPI_URL=http://localhost:8000 + +# Arcjet (server-only rate limiting / shield — copy from Arcjet dashboard) +ARCJET_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 53f4e51..1569e2c 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -11,12 +11,19 @@ "deploy:staging": "vite build && wrangler deploy --env staging", "deploy:production": "vite build && wrangler deploy --env production", "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", "format": "biome format --write", "lint": "biome lint", "check": "biome check", "check-types": "tsc --noEmit" }, "dependencies": { + "@arcjet/headers": "^1.3.1", + "@arcjet/ip": "^1.3.1", + "@arcjet/protocol": "^1.3.1", + "@arcjet/transport": "^1.3.1", + "@clerk/clerk-react": "^5.60.0", "@clerk/tanstack-react-start": "^0.29.1", "@convex-dev/react-query": "^0.1.0", "@harness/convex-backend": "workspace:*", @@ -33,8 +40,10 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", "@xterm/xterm": "^6.0.0", + "arcjet": "^1.3.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "convex": "^1.31.7", "geist": "^1.7.0", "highlight.js": "^11.11.1", @@ -51,6 +60,7 @@ "tailwindcss": "^4.1.18", "tw-animate-css": "^1.3.6", "use-sync-external-store": "^1.6.0", + "vinxi": "^0.5.11", "vite-tsconfig-paths": "^6.0.2", "zod": "^4.1.11" }, @@ -59,11 +69,14 @@ "@cloudflare/vite-plugin": "^1.30.3", "@tanstack/devtools-vite": "^0.3.11", "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", "@types/node": "^22.10.2", "@types/react": "^19.2.0", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.0.4", + "@vitest/coverage-v8": "^3.0.5", "jsdom": "^27.0.0", "typescript": "5.9.2", "vite": "^7.1.7", diff --git a/apps/web/src/components/attachment-chip.test.tsx b/apps/web/src/components/attachment-chip.test.tsx new file mode 100644 index 0000000..9d129a5 --- /dev/null +++ b/apps/web/src/components/attachment-chip.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { PendingAttachment } from "../hooks/use-file-attachments"; +import { AttachmentChip } from "./attachment-chip"; + +function makeAttachment( + partial: Partial = {}, +): PendingAttachment { + return { + localId: "id-1", + previewUrl: null, + mimeType: "image/png", + status: "ready", + fileName: "file.png", + fileSize: 100, + ...partial, + }; +} + +describe("AttachmentChip", () => { + it("renders an image preview when previewUrl is set and mime is image/*", () => { + const att = makeAttachment({ + mimeType: "image/png", + previewUrl: "blob:abc", + fileName: "pic.png", + }); + render( {}} />); + const img = screen.getByAltText("pic.png") as HTMLImageElement; + expect(img).toBeInTheDocument(); + expect(img.src).toContain("blob:abc"); + }); + + it("renders a file-name label with icon for PDFs", () => { + const att = makeAttachment({ + mimeType: "application/pdf", + fileName: "notes.pdf", + }); + render( {}} />); + expect(screen.getByText("notes.pdf")).toBeInTheDocument(); + }); + + it("renders a file-name label with icon for audio", () => { + const att = makeAttachment({ + mimeType: "audio/wav", + fileName: "sound.wav", + }); + render( {}} />); + expect(screen.getByText("sound.wav")).toBeInTheDocument(); + }); + + it("shows an uploading overlay when status is uploading", () => { + const att = makeAttachment({ status: "uploading" }); + const { container } = render( + {}} />, + ); + // Overlay uses bg-background/70 — spinner is rendered inside it. + expect(container.querySelector(".bg-background\\/70")).toBeTruthy(); + }); + + it("shows an 'Error' label when status is error", () => { + const att = makeAttachment({ status: "error" }); + render( {}} />); + expect(screen.getByText("Error")).toBeInTheDocument(); + }); + + it("calls onRemove when the X button is clicked", () => { + const onRemove = vi.fn(); + const att = makeAttachment({ + mimeType: "application/pdf", + fileName: "a.pdf", + }); + render(); + // The X button is the only + + ); + })} + + {authRequiredMcps.length > 0 && ( +

+ Integrations with {" "} + require sign-in after creation. +

+ )} + + )} + + + {/* Skills chips */} + {editedConfig.skillIds.length > 0 && ( +
+

Skills

+
+ {editedConfig.skillIds.map((id) => { + const skill = availableSkills.find((s) => s.id === id); + return ( + + {id.split("/").pop() ?? id} + + + ); + })} +
+
+ )} + + {/* Config rating */} +
+

+ Was this suggestion helpful? +

+
+ + +
+
+ + {/* Duplicate harness warning */} + {similarHarness && ( +
+

+ You already have a similar harness:{" "} + {similarHarness.name} +

+
+ + +
+
+ )} + + {/* Action buttons */} + {!similarHarness && ( +
+ + +
+ )} + + )} + + {/* Context pane */} + {showContextPane && ( +
+
+
+ + + {contextFileName ? contextFileName : "Context"} + + + {contextCharCount}/{CONTEXT_MAX_CHARS} + +
+ +
+