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:
parent
317e87fb97
commit
1ba0fb071a
15 changed files with 418 additions and 28 deletions
61
CLAUDE.md
61
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
|
||||
});
|
||||
```
|
||||
|
|
|
|||
32
Makefile
32
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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
70
e2e/fixtures.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
42
e2e/settings.spec.ts
Normal 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
124
scripts/check.sh
Executable 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; }
|
||||
Loading…
Add table
Add a link
Reference in a new issue