diff --git a/CLAUDE.md b/CLAUDE.md index f105d3d3..d7c67f65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -74,7 +74,62 @@ docker compose down # Stop PostgreSQL ## 7. Minimum Pre-Push Checks ```bash -pnpm typecheck -pnpm test -make test +make check # Runs all checks: typecheck, unit tests, Go tests, E2E +``` + +For individual checks during development: +```bash +pnpm typecheck # TypeScript type errors only +pnpm test # TS unit tests only (Vitest) +make test # Go tests only +pnpm exec playwright test # E2E only (requires backend + frontend running) +``` + +## 8. AI Agent Verification Loop + +After writing or modifying code, always run the full verification pipeline: + +```bash +make check +``` + +This runs all checks in sequence: +1. TypeScript typecheck (`pnpm typecheck`) +2. TypeScript unit tests (`pnpm test`) +3. Go tests (`go test ./...`) +4. E2E tests (auto-starts backend + frontend if needed, runs Playwright) + +**Workflow:** +- Write code to satisfy the requirement +- Run `make check` +- If any step fails, read the error output, fix the code, and re-run `make check` +- Repeat until all checks pass +- Only then consider the task complete + +**Quick iteration:** If you know only TypeScript or Go is affected, run individual checks first for faster feedback, then finish with a full `make check` before marking work complete. + +## 9. E2E Test Patterns + +E2E tests should be self-contained. Use the `TestApiClient` fixture for data setup/teardown: + +```typescript +import { loginAsDefault, createTestApi } from "./helpers"; +import type { TestApiClient } from "./fixtures"; + +let api: TestApiClient; + +test.beforeEach(async ({ page }) => { + api = await createTestApi(); // logged-in API client + await loginAsDefault(page); // browser session +}); + +test.afterEach(async () => { + await api.cleanup(); // delete any data created during the test +}); + +test("example", async ({ page }) => { + const issue = await api.createIssue("Test Issue"); // create via API + await page.goto(`/issues/${issue.id}`); // test via UI + // api.cleanup() in afterEach removes the issue +}); ``` diff --git a/Makefile b/Makefile index 1b3ba01c..3736cd27 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop +.PHONY: dev daemon build test migrate-up migrate-down sqlc seed clean setup start stop check # ---------- One-click commands ---------- @@ -7,11 +7,15 @@ setup: @echo "==> Installing dependencies..." pnpm install @echo "==> Starting PostgreSQL..." - docker compose up -d - @echo "==> Waiting for PostgreSQL to be ready..." - @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ - sleep 1; \ - done + @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + echo " PostgreSQL already running, skipping docker compose up."; \ + else \ + docker compose up -d; \ + echo "==> Waiting for PostgreSQL to be ready..."; \ + until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done; \ + fi @echo "==> Running migrations..." cd server && go run ./cmd/migrate up @echo "==> Seeding data..." @@ -21,10 +25,14 @@ setup: # Start all services (backend + frontend) start: - @docker compose up -d - @until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ - sleep 1; \ - done + @if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then \ + echo "PostgreSQL already running, skipping docker compose up."; \ + else \ + docker compose up -d; \ + until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do \ + sleep 1; \ + done; \ + fi @echo "Starting backend and frontend..." @trap 'kill 0' EXIT; \ (cd server && go run ./cmd/server) & \ @@ -39,6 +47,10 @@ stop: docker compose down @echo "✓ All services stopped." +# Full verification: typecheck + unit tests + Go tests + E2E +check: + @bash scripts/check.sh + # ---------- Individual commands ---------- # Go server diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 7ebb0c00..c8f03704 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { Bot, Cloud, @@ -9,8 +9,9 @@ import { Zap, ListTodo, } from "lucide-react"; -import type { Agent, AgentStatus } from "@multica/types"; +import type { Agent, AgentStatus, AgentStatusPayload } from "@multica/types"; import { api } from "../../../lib/api"; +import { useWSEvent } from "../../../lib/ws-context"; // --------------------------------------------------------------------------- // Helpers @@ -178,6 +179,14 @@ export default function AgentsPage() { .finally(() => setLoading(false)); }, []); + useWSEvent( + "agent:status", + useCallback((payload: unknown) => { + const { agent } = payload as AgentStatusPayload; + setAgents((prev) => prev.map((a) => (a.id === agent.id ? agent : a))); + }, []), + ); + const selected = agents.find((a) => a.id === selectedId) ?? null; if (loading) { diff --git a/apps/web/app/(dashboard)/inbox/page.tsx b/apps/web/app/(dashboard)/inbox/page.tsx index a2898b11..8b729d7d 100644 --- a/apps/web/app/(dashboard)/inbox/page.tsx +++ b/apps/web/app/(dashboard)/inbox/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { AlertCircle, Bot, @@ -10,8 +10,9 @@ import { MessageSquare, ArrowRightLeft, } from "lucide-react"; -import type { InboxItem, InboxItemType, InboxSeverity } from "@multica/types"; +import type { InboxItem, InboxItemType, InboxSeverity, InboxNewPayload } from "@multica/types"; import { api } from "../../../lib/api"; +import { useWSEvent } from "../../../lib/ws-context"; // --------------------------------------------------------------------------- // Helpers @@ -167,6 +168,17 @@ export default function InboxPage() { .finally(() => setLoading(false)); }, []); + useWSEvent( + "inbox:new", + useCallback((payload: unknown) => { + const { item } = payload as InboxNewPayload; + setItems((prev) => { + if (prev.some((i) => i.id === item.id)) return prev; + return [item, ...prev]; + }); + }, []), + ); + const handleMarkRead = async (id: string) => { try { await api.markInboxRead(id); diff --git a/apps/web/app/(dashboard)/issues/[id]/page.tsx b/apps/web/app/(dashboard)/issues/[id]/page.tsx index 9c70d5ac..0f0ec55a 100644 --- a/apps/web/app/(dashboard)/issues/[id]/page.tsx +++ b/apps/web/app/(dashboard)/issues/[id]/page.tsx @@ -111,6 +111,9 @@ export default function IssueDetailPage({ const [submitting, setSubmitting] = useState(false); useEffect(() => { + setIssue(null); + setComments([]); + setLoading(true); Promise.all([api.getIssue(id), api.listComments(id)]) .then(([iss, cmts]) => { setIssue(iss); diff --git a/apps/web/app/(dashboard)/issues/page.tsx b/apps/web/app/(dashboard)/issues/page.tsx index 127b606a..509bd2ee 100644 --- a/apps/web/app/(dashboard)/issues/page.tsx +++ b/apps/web/app/(dashboard)/issues/page.tsx @@ -215,7 +215,7 @@ function DroppableColumn({ const { setNodeRef, isOver } = useDroppable({ id: status }); return ( -
+
{cfg.label} @@ -514,7 +514,10 @@ export default function IssuesPage() { ); const handleIssueCreated = useCallback((issue: Issue) => { - setIssues((prev) => [...prev, issue]); + setIssues((prev) => { + if (prev.some((i) => i.id === issue.id)) return prev; + return [...prev, issue]; + }); }, []); if (loading) { diff --git a/apps/web/app/(dashboard)/settings/page.tsx b/apps/web/app/(dashboard)/settings/page.tsx index a8ce15d5..2e1f8992 100644 --- a/apps/web/app/(dashboard)/settings/page.tsx +++ b/apps/web/app/(dashboard)/settings/page.tsx @@ -39,7 +39,7 @@ function MemberRow({ member }: { member: MemberWithUser }) { } export default function SettingsPage() { - const { workspace, members } = useAuth(); + const { workspace, members, updateWorkspace } = useAuth(); const [name, setName] = useState(workspace?.name ?? ""); const [description, setDescription] = useState( @@ -52,10 +52,11 @@ export default function SettingsPage() { if (!workspace) return; setSaving(true); try { - await api.updateWorkspace(workspace.id, { + const updated = await api.updateWorkspace(workspace.id, { name, description: description || undefined, }); + updateWorkspace(updated); setSaved(true); setTimeout(() => setSaved(false), 2000); } catch (e) { diff --git a/apps/web/lib/auth-context.test.tsx b/apps/web/lib/auth-context.test.tsx index 5acccb96..8d3d8e3b 100644 --- a/apps/web/lib/auth-context.test.tsx +++ b/apps/web/lib/auth-context.test.tsx @@ -264,6 +264,33 @@ describe("AuthContext", () => { expect(result.current.workspace).toEqual(mockWorkspace); }); + it("updateWorkspace updates workspace in context", async () => { + mockApi.login.mockResolvedValueOnce({ token: "test-jwt", user: mockUser }); + mockApi.listWorkspaces.mockResolvedValueOnce([mockWorkspace]); + mockApi.listMembers.mockResolvedValueOnce(mockMembers); + mockApi.listAgents.mockResolvedValueOnce(mockAgents); + + const { result } = renderHook(() => useAuth(), { wrapper }); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + await act(async () => { + await result.current.login("test@multica.ai"); + }); + + expect(result.current.workspace?.name).toBe("Test WS"); + + const updated: Workspace = { ...mockWorkspace, name: "Renamed WS", description: "new desc" }; + act(() => { + result.current.updateWorkspace(updated); + }); + + expect(result.current.workspace?.name).toBe("Renamed WS"); + expect(result.current.workspace?.description).toBe("new desc"); + }); + it("clears token when stored token is invalid", async () => { localStorage.setItem("multica_token", "invalid-token"); diff --git a/apps/web/lib/auth-context.tsx b/apps/web/lib/auth-context.tsx index 6b3391d3..72eb2805 100644 --- a/apps/web/lib/auth-context.tsx +++ b/apps/web/lib/auth-context.tsx @@ -20,6 +20,7 @@ interface AuthContextValue { isLoading: boolean; login: (email: string, name?: string) => Promise; logout: () => void; + updateWorkspace: (ws: Workspace) => void; refreshMembers: () => Promise; refreshAgents: () => Promise; getMemberName: (userId: string) => string; @@ -114,6 +115,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { router.push("/login"); }, [router]); + const updateWorkspaceState = useCallback((ws: Workspace) => { + setWorkspace(ws); + }, []); + const refreshMembers = useCallback(async () => { if (!workspace) return; const m = await api.listMembers(workspace.id); @@ -174,6 +179,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { isLoading, login, logout, + updateWorkspace: updateWorkspaceState, refreshMembers, refreshAgents, getMemberName, diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 00000000..02ba1beb --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,70 @@ +/** + * TestApiClient — lightweight API helper for E2E test data setup/teardown. + * + * Uses raw fetch (no dependency on @multica/sdk build) so E2E tests + * have zero build-time coupling to monorepo packages. + */ + +const API_BASE = "http://localhost:8080"; + +export class TestApiClient { + private token: string | null = null; + private workspaceId: string | null = null; + private createdIssueIds: string[] = []; + + async login(email: string, name: string) { + const res = await fetch(`${API_BASE}/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name }), + }); + const data = await res.json(); + this.token = data.token; + return data; + } + + async getWorkspaces() { + const res = await this.authedFetch("/api/workspaces"); + return res.json(); + } + + setWorkspaceId(id: string) { + this.workspaceId = id; + } + + async createIssue(title: string, opts?: Record) { + const res = await this.authedFetch("/api/issues", { + method: "POST", + body: JSON.stringify({ title, ...opts }), + }); + const issue = await res.json(); + this.createdIssueIds.push(issue.id); + return issue; + } + + async deleteIssue(id: string) { + await this.authedFetch(`/api/issues/${id}`, { method: "DELETE" }); + } + + /** Clean up all issues created during this test. */ + async cleanup() { + for (const id of this.createdIssueIds) { + try { + await this.deleteIssue(id); + } catch { + /* ignore — may already be deleted */ + } + } + this.createdIssueIds = []; + } + + private async authedFetch(path: string, init?: RequestInit) { + const headers: Record = { + "Content-Type": "application/json", + ...((init?.headers as Record) ?? {}), + }; + if (this.token) headers["Authorization"] = `Bearer ${this.token}`; + if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId; + return fetch(`${API_BASE}${path}`, { ...init, headers }); + } +} diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 3e01f9a1..150ddd0e 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -1,4 +1,5 @@ import { type Page } from "@playwright/test"; +import { TestApiClient } from "./fixtures"; /** * Login as the seeded user (has workspace and issues). @@ -14,6 +15,20 @@ export async function loginAsDefault(page: Page) { /** * Open the workspace switcher dropdown menu. */ +/** + * Create a TestApiClient logged in as the default seeded user. + * Call api.cleanup() in afterEach to remove test data created during the test. + */ +export async function createTestApi(): Promise { + const api = new TestApiClient(); + await api.login("jiayuan@multica.ai", "Jiayuan Zhang"); + const workspaces = await api.getWorkspaces(); + if (workspaces.length > 0) { + api.setWorkspaceId(workspaces[0].id); + } + return api; +} + export async function openWorkspaceMenu(page: Page) { // Click the workspace switcher button (has ChevronDown icon) await page.locator("aside button").first().click(); diff --git a/e2e/issues.spec.ts b/e2e/issues.spec.ts index 73dc3c39..1feeb7c0 100644 --- a/e2e/issues.spec.ts +++ b/e2e/issues.spec.ts @@ -1,11 +1,19 @@ import { test, expect } from "@playwright/test"; -import { loginAsDefault } from "./helpers"; +import { loginAsDefault, createTestApi } from "./helpers"; +import type { TestApiClient } from "./fixtures"; test.describe("Issues", () => { + let api: TestApiClient; + test.beforeEach(async ({ page }) => { + api = await createTestApi(); await loginAsDefault(page); }); + test.afterEach(async () => { + await api.cleanup(); + }); + test("issues page loads with board view", async ({ page }) => { await expect(page.locator("text=All Issues")).toBeVisible(); @@ -41,15 +49,18 @@ test.describe("Issues", () => { }); test("can navigate to issue detail page", async ({ page }) => { - // Wait for issues to load + // Create a known issue via API so we don't depend on seed data + const issue = await api.createIssue("E2E Detail Test " + Date.now()); + + // Reload to see the new issue + await page.reload(); await expect(page.locator("text=All Issues")).toBeVisible(); - // Click first issue card that has an anchor tag to issue detail - const issueLink = page.locator('a[href^="/issues/"]').first(); + // Navigate to the issue detail + const issueLink = page.locator(`a[href="/issues/${issue.id}"]`); await expect(issueLink).toBeVisible({ timeout: 5000 }); await issueLink.click(); - // Should navigate to issue detail await page.waitForURL(/\/issues\/[\w-]+/); // Should show Properties panel diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts index ae0288f8..7f67d6b8 100644 --- a/e2e/navigation.spec.ts +++ b/e2e/navigation.spec.ts @@ -29,8 +29,8 @@ test.describe("Navigation", () => { await page.locator("text=Settings").click(); await page.waitForURL("**/settings"); - await expect(page.locator("text=Workspace")).toBeVisible(); - await expect(page.locator("text=Members")).toBeVisible(); + await expect(page.getByRole("heading", { name: "Workspace" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Members" })).toBeVisible(); }); test("agents page shows agent list", async ({ page }) => { diff --git a/e2e/settings.spec.ts b/e2e/settings.spec.ts new file mode 100644 index 00000000..1ad2043f --- /dev/null +++ b/e2e/settings.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; +import { loginAsDefault, openWorkspaceMenu } from "./helpers"; + +test.describe("Settings", () => { + test("updating workspace name reflects in sidebar immediately", async ({ + page, + }) => { + await loginAsDefault(page); + + // Read the current workspace name from the sidebar + const sidebarName = page.locator("aside button").first(); + const originalName = await sidebarName.innerText(); + + // Navigate to settings + await openWorkspaceMenu(page); + await page.locator("text=Settings").click(); + await page.waitForURL("**/settings"); + + // Change workspace name + const nameInput = page + .locator('input[type="text"]') + .first(); + await nameInput.clear(); + const newName = "Renamed WS " + Date.now(); + await nameInput.fill(newName); + + // Save + await page.locator("button", { hasText: "Save" }).click(); + + // Wait for "Saved!" confirmation + await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 }); + + // Sidebar should reflect the new name WITHOUT page refresh + await expect(sidebarName).toContainText(newName); + + // Restore original name so other tests aren't affected + await nameInput.clear(); + await nameInput.fill(originalName.trim()); + await page.locator("button", { hasText: "Save" }).click(); + await expect(page.locator("text=Saved!")).toBeVisible({ timeout: 5000 }); + }); +}); diff --git a/scripts/check.sh b/scripts/check.sh new file mode 100755 index 00000000..1cddbea1 --- /dev/null +++ b/scripts/check.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +# ========================================================================== +# Full verification pipeline: typecheck → unit tests → Go tests → E2E +# Usage: bash scripts/check.sh +# ========================================================================== + +BACKEND_PID="" +FRONTEND_PID="" +STARTED_BACKEND=false +STARTED_FRONTEND=false +EXIT_CODE=0 + +# -------------------------------------------------------------------------- +# Cleanup: kill only services this script started +# -------------------------------------------------------------------------- +cleanup() { + echo "" + if [ "$STARTED_BACKEND" = true ] && [ -n "$BACKEND_PID" ]; then + kill "$BACKEND_PID" 2>/dev/null && wait "$BACKEND_PID" 2>/dev/null || true + echo " Stopped backend (PID $BACKEND_PID)" + fi + if [ "$STARTED_FRONTEND" = true ] && [ -n "$FRONTEND_PID" ]; then + kill "$FRONTEND_PID" 2>/dev/null && wait "$FRONTEND_PID" 2>/dev/null || true + echo " Stopped frontend (PID $FRONTEND_PID)" + fi + echo "" + if [ "$EXIT_CODE" -eq 0 ]; then + echo "✓ All checks passed." + else + echo "✗ Checks FAILED." + fi + exit "$EXIT_CODE" +} +trap cleanup EXIT + +# -------------------------------------------------------------------------- +# Utility: wait until a port responds +# -------------------------------------------------------------------------- +wait_for_port() { + local port=$1 name=$2 max_wait=${3:-60} path=${4:-/} + local elapsed=0 + echo " Waiting for $name on :$port..." + while ! curl -sf "http://localhost:${port}${path}" > /dev/null 2>&1; do + sleep 1 + elapsed=$((elapsed + 1)) + if [ "$elapsed" -ge "$max_wait" ]; then + echo " ERROR: $name did not start within ${max_wait}s" + EXIT_CODE=1 + exit 1 + fi + done + echo " $name ready (${elapsed}s)" +} + +# -------------------------------------------------------------------------- +# Step 0: Ensure DB +# -------------------------------------------------------------------------- +echo "==> Checking PostgreSQL..." +if pg_isready -h localhost -p 5432 -U multica > /dev/null 2>&1; then + echo " Already running." +else + echo " Starting via docker compose..." + docker compose up -d + until docker compose exec -T postgres pg_isready -U multica > /dev/null 2>&1; do + sleep 1 + done + echo " PostgreSQL ready." +fi + +# -------------------------------------------------------------------------- +# Step 1: TypeScript typecheck +# -------------------------------------------------------------------------- +echo "" +echo "==> [1/5] TypeScript typecheck..." +pnpm typecheck || { EXIT_CODE=1; exit 1; } + +# -------------------------------------------------------------------------- +# Step 2: TypeScript unit tests (Vitest) +# -------------------------------------------------------------------------- +echo "" +echo "==> [2/5] TypeScript unit tests..." +pnpm test || { EXIT_CODE=1; exit 1; } + +# -------------------------------------------------------------------------- +# Step 3: Go tests +# -------------------------------------------------------------------------- +echo "" +echo "==> [3/5] Go tests..." +(cd server && go test ./...) || { EXIT_CODE=1; exit 1; } + +# -------------------------------------------------------------------------- +# Step 4: Start services for E2E (only if not already running) +# -------------------------------------------------------------------------- +echo "" +echo "==> [4/5] Starting services for E2E..." + +if curl -sf http://localhost:8080/health > /dev/null 2>&1; then + echo " Backend already running on :8080" +else + echo " Starting backend..." + (cd server && go run ./cmd/server) > /tmp/multica-check-backend.log 2>&1 & + BACKEND_PID=$! + STARTED_BACKEND=true + wait_for_port 8080 "Backend" 90 "/health" +fi + +if curl -sf http://localhost:3000 > /dev/null 2>&1; then + echo " Frontend already running on :3000" +else + echo " Starting frontend..." + pnpm dev:web > /tmp/multica-check-frontend.log 2>&1 & + FRONTEND_PID=$! + STARTED_FRONTEND=true + wait_for_port 3000 "Frontend" 120 "/" +fi + +# -------------------------------------------------------------------------- +# Step 5: E2E tests (Playwright) +# -------------------------------------------------------------------------- +echo "" +echo "==> [5/5] E2E tests (Playwright)..." +pnpm exec playwright test || { EXIT_CODE=1; exit 1; }