fix(web): fix stale state bugs, add real-time updates, and build verification pipeline

- Fix kanban board columns not adapting to available width (w-64 → flex-1)
- Fix workspace name not updating in sidebar after save in settings
- Fix comments leaking across issues when navigating between issue details
- Fix duplicate issue appearing on create (race between callback and WebSocket)
- Add real-time WebSocket listeners for agents and inbox pages
- Add `make check` one-click verification pipeline (typecheck + tests + E2E)
- Add E2E test fixtures for self-contained test data setup/teardown
- Add settings E2E test and updateWorkspace unit test
- Make `make start/setup` reuse existing PostgreSQL if already running
- Update CLAUDE.md with AI agent verification loop and E2E test patterns

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jiayuan Zhang 2026-03-22 12:44:49 +08:00
parent 317e87fb97
commit 1ba0fb071a
15 changed files with 418 additions and 28 deletions

View file

@ -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
});
```

View file

@ -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 \
@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
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 \
@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
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

View file

@ -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) {

View file

@ -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);

View file

@ -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);

View file

@ -215,7 +215,7 @@ function DroppableColumn({
const { setNodeRef, isOver } = useDroppable({ id: status });
return (
<div className="flex w-64 shrink-0 flex-col">
<div className="flex min-w-52 flex-1 flex-col">
<div className="mb-2 flex items-center gap-2 px-1">
<StatusIcon status={status} className="h-3.5 w-3.5" />
<span className="text-xs font-medium">{cfg.label}</span>
@ -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) {

View file

@ -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) {

View file

@ -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");

View file

@ -20,6 +20,7 @@ interface AuthContextValue {
isLoading: boolean;
login: (email: string, name?: string) => Promise<void>;
logout: () => void;
updateWorkspace: (ws: Workspace) => void;
refreshMembers: () => Promise<void>;
refreshAgents: () => Promise<void>;
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,

70
e2e/fixtures.ts Normal file
View file

@ -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<string, unknown>) {
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<string, string> = {
"Content-Type": "application/json",
...((init?.headers as Record<string, string>) ?? {}),
};
if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
if (this.workspaceId) headers["X-Workspace-ID"] = this.workspaceId;
return fetch(`${API_BASE}${path}`, { ...init, headers });
}
}

View file

@ -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<TestApiClient> {
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();

View file

@ -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

View file

@ -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 }) => {

42
e2e/settings.spec.ts Normal file
View file

@ -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 });
});
});

124
scripts/check.sh Executable file
View file

@ -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; }