diff --git a/apps/web/app/(auth)/login/page.test.tsx b/apps/web/app/(auth)/login/page.test.tsx
new file mode 100644
index 00000000..cb49d361
--- /dev/null
+++ b/apps/web/app/(auth)/login/page.test.tsx
@@ -0,0 +1,116 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+// Mock next/navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/login",
+}));
+
+// Mock auth-context
+const mockLogin = vi.fn();
+const mockAuthValue = {
+ user: null,
+ workspace: null,
+ members: [],
+ agents: [],
+ isLoading: false,
+ login: mockLogin,
+ logout: vi.fn(),
+ refreshMembers: vi.fn(),
+ refreshAgents: vi.fn(),
+ getMemberName: () => "Unknown",
+ getAgentName: () => "Unknown Agent",
+ getActorName: () => "System",
+ getActorInitials: () => "XX",
+};
+
+vi.mock("../../../lib/auth-context", () => ({
+ useAuth: () => mockAuthValue,
+ AuthProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+import LoginPage from "./page";
+
+describe("LoginPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders login form with heading, inputs, and button", () => {
+ render();
+
+ expect(screen.getByText("Multica")).toBeInTheDocument();
+ expect(screen.getByText("AI-native task management")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Email")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Name")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Sign in" })).toBeInTheDocument();
+ });
+
+ it("does not call login when email is empty", async () => {
+ const user = userEvent.setup();
+ render();
+
+ // The email input has required attribute, so browser validation blocks submit
+ // Verify login was never called
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+ expect(mockLogin).not.toHaveBeenCalled();
+ });
+
+ it("calls login with correct args on submit", async () => {
+ mockLogin.mockResolvedValueOnce(undefined);
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.type(screen.getByPlaceholderText("Name"), "Test User");
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", "Test User");
+ });
+ });
+
+ it("calls login with email only when name is empty", async () => {
+ mockLogin.mockResolvedValueOnce(undefined);
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+
+ await waitFor(() => {
+ expect(mockLogin).toHaveBeenCalledWith("test@multica.ai", undefined);
+ });
+ });
+
+ it("shows 'Signing in...' while submitting", async () => {
+ // Make login hang
+ mockLogin.mockReturnValueOnce(new Promise(() => {}));
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+
+ await waitFor(() => {
+ expect(screen.getByText("Signing in...")).toBeInTheDocument();
+ });
+ });
+
+ it("shows error when login fails", async () => {
+ mockLogin.mockRejectedValueOnce(new Error("Network error"));
+ const user = userEvent.setup();
+ render();
+
+ await user.type(screen.getByPlaceholderText("Email"), "test@multica.ai");
+ await user.click(screen.getByRole("button", { name: "Sign in" }));
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Login failed. Make sure the server is running."),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/app/(dashboard)/issues/[id]/page.test.tsx b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
new file mode 100644
index 00000000..340e377a
--- /dev/null
+++ b/apps/web/app/(dashboard)/issues/[id]/page.test.tsx
@@ -0,0 +1,241 @@
+import { Suspense } from "react";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor, act } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Issue, Comment } from "@multica/types";
+
+// Mock next/navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/issues/issue-1",
+}));
+
+// Mock next/link
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ href,
+ ...props
+ }: {
+ children: React.ReactNode;
+ href: string;
+ [key: string]: any;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock auth context
+vi.mock("../../../../lib/auth-context", () => ({
+ useAuth: () => ({
+ user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
+ workspace: { id: "ws-1", name: "Test WS" },
+ members: [
+ { user_id: "user-1", name: "Test User", email: "test@multica.ai" },
+ ],
+ agents: [{ id: "agent-1", name: "Claude Agent" }],
+ isLoading: false,
+ getActorName: (type: string, id: string) => {
+ if (type === "member" && id === "user-1") return "Test User";
+ if (type === "agent" && id === "agent-1") return "Claude Agent";
+ return "Unknown";
+ },
+ getActorInitials: (type: string, id: string) => {
+ if (type === "member") return "TU";
+ if (type === "agent") return "CA";
+ return "??";
+ },
+ }),
+}));
+
+// Mock api
+const mockGetIssue = vi.hoisted(() => vi.fn());
+const mockListComments = vi.hoisted(() => vi.fn());
+const mockCreateComment = vi.hoisted(() => vi.fn());
+
+vi.mock("../../../../lib/api", () => ({
+ api: {
+ getIssue: (...args: any[]) => mockGetIssue(...args),
+ listComments: (...args: any[]) => mockListComments(...args),
+ createComment: (...args: any[]) => mockCreateComment(...args),
+ },
+}));
+
+const mockIssue: Issue = {
+ id: "issue-1",
+ workspace_id: "ws-1",
+ title: "Implement authentication",
+ description: "Add JWT auth to the backend",
+ status: "in_progress",
+ priority: "high",
+ assignee_type: "member",
+ assignee_id: "user-1",
+ creator_type: "member",
+ creator_id: "user-1",
+ due_date: "2026-06-01T00:00:00Z",
+ created_at: "2026-01-15T00:00:00Z",
+ updated_at: "2026-01-20T00:00:00Z",
+};
+
+const mockComments: Comment[] = [
+ {
+ id: "comment-1",
+ issue_id: "issue-1",
+ content: "Started working on this",
+ type: "comment",
+ author_type: "member",
+ author_id: "user-1",
+ created_at: "2026-01-16T00:00:00Z",
+ updated_at: "2026-01-16T00:00:00Z",
+ },
+ {
+ id: "comment-2",
+ issue_id: "issue-1",
+ content: "I can help with this",
+ type: "comment",
+ author_type: "agent",
+ author_id: "agent-1",
+ created_at: "2026-01-17T00:00:00Z",
+ updated_at: "2026-01-17T00:00:00Z",
+ },
+];
+
+import IssueDetailPage from "./page";
+
+// React 19 use(Promise) needs the promise to resolve within act + Suspense
+async function renderPage(id = "issue-1") {
+ let result: ReturnType;
+ await act(async () => {
+ result = render(
+ Suspense loading...}>
+
+ ,
+ );
+ });
+ return result!;
+}
+
+describe("IssueDetailPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders issue details after loading", async () => {
+ mockGetIssue.mockResolvedValueOnce(mockIssue);
+ mockListComments.mockResolvedValueOnce(mockComments);
+ await renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Implement authentication"),
+ ).toBeInTheDocument();
+ });
+
+ expect(
+ screen.getByText("Add JWT auth to the backend"),
+ ).toBeInTheDocument();
+ });
+
+ it("renders issue properties sidebar", async () => {
+ mockGetIssue.mockResolvedValueOnce(mockIssue);
+ mockListComments.mockResolvedValueOnce(mockComments);
+ await renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Properties")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("In Progress")).toBeInTheDocument();
+ expect(screen.getByText("High")).toBeInTheDocument();
+ });
+
+ it("renders comments", async () => {
+ mockGetIssue.mockResolvedValueOnce(mockIssue);
+ mockListComments.mockResolvedValueOnce(mockComments);
+ await renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByText("Started working on this"),
+ ).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("I can help with this")).toBeInTheDocument();
+ expect(screen.getByText("Activity")).toBeInTheDocument();
+ });
+
+ it("shows 'Issue not found' for missing issue", async () => {
+ mockGetIssue.mockRejectedValueOnce(new Error("Not found"));
+ mockListComments.mockRejectedValueOnce(new Error("Not found"));
+ await renderPage("nonexistent-id");
+
+ await waitFor(() => {
+ expect(screen.getByText("Issue not found")).toBeInTheDocument();
+ });
+ });
+
+ it("submits a new comment", async () => {
+ mockGetIssue.mockResolvedValueOnce(mockIssue);
+ mockListComments.mockResolvedValueOnce(mockComments);
+
+ const newComment: Comment = {
+ id: "comment-3",
+ issue_id: "issue-1",
+ content: "New test comment",
+ type: "comment",
+ author_type: "member",
+ author_id: "user-1",
+ created_at: "2026-01-18T00:00:00Z",
+ updated_at: "2026-01-18T00:00:00Z",
+ };
+ mockCreateComment.mockResolvedValueOnce(newComment);
+
+ const user = userEvent.setup();
+ await renderPage();
+
+ await waitFor(() => {
+ expect(
+ screen.getByPlaceholderText("Leave a comment..."),
+ ).toBeInTheDocument();
+ });
+
+ await user.type(
+ screen.getByPlaceholderText("Leave a comment..."),
+ "New test comment",
+ );
+
+ const form = screen
+ .getByPlaceholderText("Leave a comment...")
+ .closest("form")!;
+ const submitBtn = form.querySelector(
+ 'button[type="submit"]',
+ ) as HTMLElement;
+ await user.click(submitBtn);
+
+ await waitFor(() => {
+ expect(mockCreateComment).toHaveBeenCalledWith(
+ "issue-1",
+ "New test comment",
+ );
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText("New test comment")).toBeInTheDocument();
+ });
+ });
+
+ it("renders breadcrumb navigation", async () => {
+ mockGetIssue.mockResolvedValueOnce(mockIssue);
+ mockListComments.mockResolvedValueOnce(mockComments);
+ await renderPage();
+
+ await waitFor(() => {
+ expect(screen.getByText("Issues")).toBeInTheDocument();
+ });
+
+ const issuesLink = screen.getByText("Issues");
+ expect(issuesLink.closest("a")).toHaveAttribute("href", "/issues");
+ });
+});
diff --git a/apps/web/app/(dashboard)/issues/page.test.tsx b/apps/web/app/(dashboard)/issues/page.test.tsx
new file mode 100644
index 00000000..b2a454d2
--- /dev/null
+++ b/apps/web/app/(dashboard)/issues/page.test.tsx
@@ -0,0 +1,290 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Issue, ListIssuesResponse } from "@multica/types";
+
+// Mock next/navigation
+vi.mock("next/navigation", () => ({
+ useRouter: () => ({ push: vi.fn() }),
+ usePathname: () => "/issues",
+}));
+
+// Mock next/link
+vi.mock("next/link", () => ({
+ default: ({
+ children,
+ href,
+ ...props
+ }: {
+ children: React.ReactNode;
+ href: string;
+ [key: string]: any;
+ }) => (
+
+ {children}
+
+ ),
+}));
+
+// Mock auth context
+vi.mock("../../../lib/auth-context", () => ({
+ useAuth: () => ({
+ user: { id: "user-1", name: "Test User", email: "test@multica.ai" },
+ workspace: { id: "ws-1", name: "Test WS" },
+ members: [
+ { user_id: "user-1", name: "Test User", email: "test@multica.ai" },
+ ],
+ agents: [{ id: "agent-1", name: "Claude Agent" }],
+ isLoading: false,
+ getActorName: (type: string, id: string) =>
+ type === "member" ? "Test User" : "Claude Agent",
+ getActorInitials: () => "TU",
+ }),
+}));
+
+// Mock WebSocket context
+vi.mock("../../../lib/ws-context", () => ({
+ useWSEvent: vi.fn(),
+ useWS: () => ({ subscribe: vi.fn(() => () => {}) }),
+ WSProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// Mock api
+const mockListIssues = vi.fn();
+const mockCreateIssue = vi.fn();
+const mockUpdateIssue = vi.fn();
+
+vi.mock("../../../lib/api", () => ({
+ api: {
+ listIssues: (...args: any[]) => mockListIssues(...args),
+ createIssue: (...args: any[]) => mockCreateIssue(...args),
+ updateIssue: (...args: any[]) => mockUpdateIssue(...args),
+ },
+}));
+
+const mockIssues: Issue[] = [
+ {
+ id: "issue-1",
+ workspace_id: "ws-1",
+ title: "Implement auth",
+ description: "Add JWT authentication",
+ status: "todo",
+ priority: "high",
+ assignee_type: "member",
+ assignee_id: "user-1",
+ creator_type: "member",
+ creator_id: "user-1",
+ due_date: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+ {
+ id: "issue-2",
+ workspace_id: "ws-1",
+ title: "Design landing page",
+ description: null,
+ status: "in_progress",
+ priority: "medium",
+ assignee_type: "agent",
+ assignee_id: "agent-1",
+ creator_type: "member",
+ creator_id: "user-1",
+ due_date: "2026-02-01T00:00:00Z",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+ {
+ id: "issue-3",
+ workspace_id: "ws-1",
+ title: "Write tests",
+ description: null,
+ status: "backlog",
+ priority: "low",
+ assignee_type: null,
+ assignee_id: null,
+ creator_type: "member",
+ creator_id: "user-1",
+ due_date: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+];
+
+import IssuesPage from "./page";
+
+describe("IssuesPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("shows loading state initially", () => {
+ mockListIssues.mockReturnValueOnce(new Promise(() => {}));
+ render();
+ expect(screen.getByText("Loading...")).toBeInTheDocument();
+ });
+
+ it("renders issues in board view after loading", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: mockIssues,
+ total: 3,
+ } as ListIssuesResponse);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Implement auth")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Design landing page")).toBeInTheDocument();
+ expect(screen.getByText("Write tests")).toBeInTheDocument();
+ expect(screen.getByText("All Issues")).toBeInTheDocument();
+ });
+
+ it("renders board columns", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: mockIssues,
+ total: 3,
+ } as ListIssuesResponse);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Backlog")).toBeInTheDocument();
+ });
+
+ expect(screen.getByText("Todo")).toBeInTheDocument();
+ expect(screen.getByText("In Progress")).toBeInTheDocument();
+ expect(screen.getByText("In Review")).toBeInTheDocument();
+ expect(screen.getByText("Done")).toBeInTheDocument();
+ });
+
+ it("switches to list view", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: mockIssues,
+ total: 3,
+ } as ListIssuesResponse);
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("Implement auth")).toBeInTheDocument();
+ });
+
+ // Find the List button and click it
+ const listButton = screen.getByText("List");
+ await user.click(listButton);
+
+ // Issues should still be visible
+ expect(screen.getByText("Implement auth")).toBeInTheDocument();
+ expect(screen.getByText("Design landing page")).toBeInTheDocument();
+ });
+
+ it("shows 'New Issue' button and opens create form", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: [],
+ total: 0,
+ } as ListIssuesResponse);
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("New Issue")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("New Issue"));
+
+ // Create form should be visible
+ expect(
+ screen.getByPlaceholderText("Issue title..."),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Create")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ });
+
+ it("creates an issue via the form", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: [],
+ total: 0,
+ } as ListIssuesResponse);
+
+ const newIssue: Issue = {
+ id: "issue-new",
+ workspace_id: "ws-1",
+ title: "New test issue",
+ description: null,
+ status: "backlog",
+ priority: "none",
+ assignee_type: null,
+ assignee_id: null,
+ creator_type: "member",
+ creator_id: "user-1",
+ due_date: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ };
+ mockCreateIssue.mockResolvedValueOnce(newIssue);
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("New Issue")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("New Issue"));
+ await user.type(
+ screen.getByPlaceholderText("Issue title..."),
+ "New test issue",
+ );
+ await user.click(screen.getByText("Create"));
+
+ await waitFor(() => {
+ expect(mockCreateIssue).toHaveBeenCalledWith({
+ title: "New test issue",
+ });
+ });
+
+ // New issue should appear
+ await waitFor(() => {
+ expect(screen.getByText("New test issue")).toBeInTheDocument();
+ });
+ });
+
+ it("closes create form on Cancel", async () => {
+ mockListIssues.mockResolvedValueOnce({
+ issues: [],
+ total: 0,
+ } as ListIssuesResponse);
+
+ const user = userEvent.setup();
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText("New Issue")).toBeInTheDocument();
+ });
+
+ await user.click(screen.getByText("New Issue"));
+ expect(
+ screen.getByPlaceholderText("Issue title..."),
+ ).toBeInTheDocument();
+
+ await user.click(screen.getByText("Cancel"));
+ expect(
+ screen.queryByPlaceholderText("Issue title..."),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("New Issue")).toBeInTheDocument();
+ });
+
+ it("handles API error gracefully", async () => {
+ mockListIssues.mockRejectedValueOnce(new Error("Network error"));
+
+ render();
+
+ // Should finish loading without crashing
+ await waitFor(() => {
+ expect(screen.queryByText("Loading...")).not.toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/package.json b/apps/web/package.json
index dc3569bf..3d5aa923 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -8,7 +8,8 @@
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit",
- "lint": "next lint"
+ "lint": "next lint",
+ "test": "vitest run"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -28,9 +29,15 @@
},
"devDependencies": {
"@tailwindcss/postcss": "catalog:",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^16.3.2",
+ "@testing-library/user-event": "^14.6.1",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
+ "@vitejs/plugin-react": "^6.0.1",
+ "jsdom": "^29.0.1",
"tailwindcss": "catalog:",
- "typescript": "catalog:"
+ "typescript": "catalog:",
+ "vitest": "^4.1.0"
}
}
diff --git a/apps/web/test/helpers.tsx b/apps/web/test/helpers.tsx
new file mode 100644
index 00000000..58636b9c
--- /dev/null
+++ b/apps/web/test/helpers.tsx
@@ -0,0 +1,91 @@
+import React from "react";
+import { render, type RenderOptions } from "@testing-library/react";
+import type { User, Workspace, MemberWithUser, Agent } from "@multica/types";
+
+// Mock user
+export const mockUser: User = {
+ id: "user-1",
+ name: "Test User",
+ email: "test@multica.ai",
+ avatar_url: null,
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+// Mock workspace
+export const mockWorkspace: Workspace = {
+ id: "ws-1",
+ name: "Test Workspace",
+ slug: "test-ws",
+ description: "A test workspace",
+ settings: {},
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+};
+
+// Mock members
+export const mockMembers: MemberWithUser[] = [
+ {
+ id: "member-1",
+ workspace_id: "ws-1",
+ user_id: "user-1",
+ role: "owner",
+ created_at: "2026-01-01T00:00:00Z",
+ name: "Test User",
+ email: "test@multica.ai",
+ avatar_url: null,
+ },
+];
+
+// Mock agents
+export const mockAgents: Agent[] = [
+ {
+ id: "agent-1",
+ workspace_id: "ws-1",
+ name: "Claude Agent",
+ status: "idle",
+ runtime_mode: "cloud",
+ visibility: "workspace",
+ max_concurrent_tasks: 3,
+ description: "A test agent",
+ system_prompt: null,
+ config: {},
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ },
+];
+
+// Mock auth context value
+export const mockAuthValue = {
+ user: mockUser,
+ workspace: mockWorkspace,
+ members: mockMembers,
+ agents: mockAgents,
+ isLoading: false,
+ login: vi.fn(),
+ logout: vi.fn(),
+ refreshMembers: vi.fn(),
+ refreshAgents: vi.fn(),
+ getMemberName: (userId: string) => {
+ const m = mockMembers.find((m) => m.user_id === userId);
+ return m?.name ?? "Unknown";
+ },
+ getAgentName: (agentId: string) => {
+ const a = mockAgents.find((a) => a.id === agentId);
+ return a?.name ?? "Unknown Agent";
+ },
+ getActorName: (type: string, id: string) => {
+ if (type === "member") {
+ const m = mockMembers.find((m) => m.user_id === id);
+ return m?.name ?? "Unknown";
+ }
+ if (type === "agent") {
+ const a = mockAgents.find((a) => a.id === id);
+ return a?.name ?? "Unknown Agent";
+ }
+ return "System";
+ },
+ getActorInitials: (type: string, id: string) => {
+ return "TU";
+ },
+};
diff --git a/apps/web/test/setup.ts b/apps/web/test/setup.ts
new file mode 100644
index 00000000..30f7cf0f
--- /dev/null
+++ b/apps/web/test/setup.ts
@@ -0,0 +1,33 @@
+import "@testing-library/jest-dom/vitest";
+import { vi } from "vitest";
+
+// jsdom 29 / Node.js 22+ may not provide a proper Web Storage API.
+// Create a proper localStorage mock if methods are missing.
+if (
+ typeof globalThis.localStorage === "undefined" ||
+ typeof globalThis.localStorage.getItem !== "function"
+) {
+ const store: Record = {};
+ const localStorageMock = {
+ getItem: vi.fn((key: string) => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string) => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string) => {
+ delete store[key];
+ }),
+ clear: vi.fn(() => {
+ for (const key of Object.keys(store)) {
+ delete store[key];
+ }
+ }),
+ get length() {
+ return Object.keys(store).length;
+ },
+ key: vi.fn((index: number) => Object.keys(store)[index] ?? null),
+ };
+ Object.defineProperty(globalThis, "localStorage", {
+ value: localStorageMock,
+ writable: true,
+ });
+}
diff --git a/apps/web/vitest.config.ts b/apps/web/vitest.config.ts
new file mode 100644
index 00000000..5202026f
--- /dev/null
+++ b/apps/web/vitest.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
+import path from "path";
+
+export default defineConfig({
+ plugins: [react()],
+ test: {
+ environment: "jsdom",
+ globals: true,
+ setupFiles: ["./test/setup.ts"],
+ include: ["**/*.test.{ts,tsx}"],
+ },
+ resolve: {
+ alias: {
+ "@multica/types": path.resolve(__dirname, "../../packages/types/src"),
+ "@multica/sdk": path.resolve(__dirname, "../../packages/sdk/src"),
+ },
+ },
+});
diff --git a/e2e/auth.spec.ts b/e2e/auth.spec.ts
new file mode 100644
index 00000000..fabcf96e
--- /dev/null
+++ b/e2e/auth.spec.ts
@@ -0,0 +1,46 @@
+import { test, expect } from "@playwright/test";
+import { loginAsDefault, openWorkspaceMenu } from "./helpers";
+
+test.describe("Authentication", () => {
+ test("login page renders correctly", async ({ page }) => {
+ await page.goto("/login");
+
+ await expect(page.locator("h1")).toContainText("Multica");
+ await expect(page.locator('input[placeholder="Email"]')).toBeVisible();
+ await expect(page.locator('input[placeholder="Name"]')).toBeVisible();
+ await expect(page.locator('button[type="submit"]')).toContainText(
+ "Sign in",
+ );
+ });
+
+ test("login and redirect to /issues", async ({ page }) => {
+ await loginAsDefault(page);
+
+ await expect(page).toHaveURL(/\/issues/);
+ await expect(page.locator("text=All Issues")).toBeVisible();
+ });
+
+ test("unauthenticated user is redirected to /login", async ({ page }) => {
+ await page.goto("/login");
+ await page.evaluate(() => {
+ localStorage.removeItem("multica_token");
+ localStorage.removeItem("multica_workspace_id");
+ });
+
+ await page.goto("/issues");
+ await page.waitForURL("**/login", { timeout: 10000 });
+ });
+
+ test("logout redirects to /login", async ({ page }) => {
+ await loginAsDefault(page);
+
+ // Open the workspace dropdown menu
+ await openWorkspaceMenu(page);
+
+ // Click Sign out
+ await page.locator("text=Sign out").click();
+
+ await page.waitForURL("**/login", { timeout: 10000 });
+ await expect(page).toHaveURL(/\/login/);
+ });
+});
diff --git a/e2e/comments.spec.ts b/e2e/comments.spec.ts
new file mode 100644
index 00000000..d33107fd
--- /dev/null
+++ b/e2e/comments.spec.ts
@@ -0,0 +1,47 @@
+import { test, expect } from "@playwright/test";
+import { loginAsDefault } from "./helpers";
+
+test.describe("Comments", () => {
+ test("can add a comment on an issue", async ({ page }) => {
+ await loginAsDefault(page);
+
+ // Wait for issues to load and click first one
+ const issueLink = page.locator('a[href^="/issues/"]').first();
+ await expect(issueLink).toBeVisible({ timeout: 5000 });
+ await issueLink.click();
+ await page.waitForURL(/\/issues\/[\w-]+/);
+
+ // Wait for issue detail to load
+ await expect(page.locator("text=Properties")).toBeVisible();
+
+ // Type a comment
+ const commentText = "E2E comment " + Date.now();
+ const commentInput = page.locator(
+ 'input[placeholder="Leave a comment..."]',
+ );
+ await commentInput.fill(commentText);
+
+ // Submit the comment
+ await page.locator('form button[type="submit"]').last().click();
+
+ // Comment should appear in the activity section
+ await expect(page.locator(`text=${commentText}`)).toBeVisible({
+ timeout: 5000,
+ });
+ });
+
+ test("comment submit button is disabled when empty", async ({ page }) => {
+ await loginAsDefault(page);
+
+ const issueLink = page.locator('a[href^="/issues/"]').first();
+ await expect(issueLink).toBeVisible({ timeout: 5000 });
+ await issueLink.click();
+ await page.waitForURL(/\/issues\/[\w-]+/);
+
+ await expect(page.locator("text=Properties")).toBeVisible();
+
+ // Submit button should be disabled when input is empty
+ const submitBtn = page.locator('form button[type="submit"]').last();
+ await expect(submitBtn).toBeDisabled();
+ });
+});
diff --git a/e2e/helpers.ts b/e2e/helpers.ts
new file mode 100644
index 00000000..3e01f9a1
--- /dev/null
+++ b/e2e/helpers.ts
@@ -0,0 +1,22 @@
+import { type Page } from "@playwright/test";
+
+/**
+ * Login as the seeded user (has workspace and issues).
+ */
+export async function loginAsDefault(page: Page) {
+ await page.goto("/login");
+ await page.fill('input[placeholder="Name"]', "Jiayuan Zhang");
+ await page.fill('input[placeholder="Email"]', "jiayuan@multica.ai");
+ await page.click('button[type="submit"]');
+ await page.waitForURL("**/issues", { timeout: 10000 });
+}
+
+/**
+ * Open the workspace switcher dropdown menu.
+ */
+export async function openWorkspaceMenu(page: Page) {
+ // Click the workspace switcher button (has ChevronDown icon)
+ await page.locator("aside button").first().click();
+ // Wait for dropdown to appear
+ await page.locator('[class*="popover"]').waitFor({ state: "visible" });
+}
diff --git a/e2e/issues.spec.ts b/e2e/issues.spec.ts
new file mode 100644
index 00000000..aa116d4e
--- /dev/null
+++ b/e2e/issues.spec.ts
@@ -0,0 +1,77 @@
+import { test, expect } from "@playwright/test";
+import { loginAsDefault } from "./helpers";
+
+test.describe("Issues", () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAsDefault(page);
+ });
+
+ test("issues page loads with board view", async ({ page }) => {
+ await expect(page.locator("text=All Issues")).toBeVisible();
+
+ // Board columns should be visible
+ await expect(page.locator("text=Backlog")).toBeVisible();
+ await expect(page.locator("text=Todo")).toBeVisible();
+ await expect(page.locator("text=In Progress")).toBeVisible();
+ });
+
+ test("can switch between board and list view", async ({ page }) => {
+ await expect(page.locator("text=All Issues")).toBeVisible();
+
+ // Switch to list view
+ await page.click("text=List");
+ await expect(page.locator("text=All Issues")).toBeVisible();
+
+ // Switch back to board view
+ await page.click("text=Board");
+ await expect(page.locator("text=Backlog")).toBeVisible();
+ });
+
+ test("can create a new issue", async ({ page }) => {
+ await page.click("text=New Issue");
+
+ const title = "E2E Created " + Date.now();
+ await page.fill('input[placeholder="Issue title..."]', title);
+ await page.click("text=Create");
+
+ // New issue should appear on the page (may need API call to complete)
+ await expect(page.locator(`text=${title}`)).toBeVisible({
+ timeout: 10000,
+ });
+ });
+
+ test("can navigate to issue detail page", async ({ page }) => {
+ // Wait for issues to load
+ 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();
+ await expect(issueLink).toBeVisible({ timeout: 5000 });
+ await issueLink.click();
+
+ // Should navigate to issue detail
+ await page.waitForURL(/\/issues\/[\w-]+/);
+
+ // Should show Properties panel
+ await expect(page.locator("text=Properties")).toBeVisible();
+ // Should show breadcrumb link back to Issues
+ await expect(
+ page.locator("a", { hasText: "Issues" }).first(),
+ ).toBeVisible();
+ });
+
+ test("can cancel issue creation", async ({ page }) => {
+ await page.click("text=New Issue");
+
+ await expect(
+ page.locator('input[placeholder="Issue title..."]'),
+ ).toBeVisible();
+
+ await page.click("text=Cancel");
+
+ await expect(
+ page.locator('input[placeholder="Issue title..."]'),
+ ).not.toBeVisible();
+ await expect(page.locator("text=New Issue")).toBeVisible();
+ });
+});
diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts
new file mode 100644
index 00000000..ae0288f8
--- /dev/null
+++ b/e2e/navigation.spec.ts
@@ -0,0 +1,43 @@
+import { test, expect } from "@playwright/test";
+import { loginAsDefault, openWorkspaceMenu } from "./helpers";
+
+test.describe("Navigation", () => {
+ test.beforeEach(async ({ page }) => {
+ await loginAsDefault(page);
+ });
+
+ test("sidebar navigation works", async ({ page }) => {
+ // Click Inbox
+ await page.locator("nav a", { hasText: "Inbox" }).click();
+ await page.waitForURL("**/inbox");
+ await expect(page).toHaveURL(/\/inbox/);
+
+ // Click Agents
+ await page.locator("nav a", { hasText: "Agents" }).click();
+ await page.waitForURL("**/agents");
+ await expect(page).toHaveURL(/\/agents/);
+
+ // Click Issues
+ await page.locator("nav a", { hasText: "Issues" }).click();
+ await page.waitForURL("**/issues");
+ await expect(page).toHaveURL(/\/issues/);
+ });
+
+ test("settings page loads via workspace menu", async ({ page }) => {
+ // Settings is inside the workspace dropdown menu
+ await openWorkspaceMenu(page);
+ await page.locator("text=Settings").click();
+ await page.waitForURL("**/settings");
+
+ await expect(page.locator("text=Workspace")).toBeVisible();
+ await expect(page.locator("text=Members")).toBeVisible();
+ });
+
+ test("agents page shows agent list", async ({ page }) => {
+ await page.locator("nav a", { hasText: "Agents" }).click();
+ await page.waitForURL("**/agents");
+
+ // Should show "Agents" heading
+ await expect(page.locator("text=Agents").first()).toBeVisible();
+ });
+});
diff --git a/package.json b/package.json
index 80515844..324439de 100644
--- a/package.json
+++ b/package.json
@@ -22,6 +22,7 @@
}
},
"devDependencies": {
+ "@playwright/test": "^1.58.2",
"@types/node": "catalog:",
"turbo": "^2.5.0",
"typescript": "catalog:"
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 00000000..e3c16b5e
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./e2e",
+ timeout: 30000,
+ retries: 0,
+ use: {
+ baseURL: "http://localhost:3000",
+ headless: true,
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { browserName: "chromium" },
+ },
+ ],
+ // Don't auto-start servers — they must be running already
+ // This avoids complexity and port conflicts during testing
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 95e860b6..c188b23b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -48,6 +48,9 @@ importers:
.:
devDependencies:
+ '@playwright/test':
+ specifier: ^1.58.2
+ version: 1.58.2
'@types/node':
specifier: 'catalog:'
version: 25.5.0
@@ -92,7 +95,7 @@ importers:
version: 0.511.0(react@19.2.3)
next:
specifier: ^16.1.6
- version: 16.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ version: 16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
@@ -106,18 +109,36 @@ importers:
'@tailwindcss/postcss':
specifier: 'catalog:'
version: 4.2.2
+ '@testing-library/jest-dom':
+ specifier: ^6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: ^16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
+ '@testing-library/user-event':
+ specifier: ^14.6.1
+ version: 14.6.1(@testing-library/dom@10.4.1)
'@types/react':
specifier: ^19.2.0
version: 19.2.14
'@types/react-dom':
specifier: ^19.2.0
version: 19.2.3(@types/react@19.2.14)
+ '@vitejs/plugin-react':
+ specifier: ^6.0.1
+ version: 6.0.1(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
+ jsdom:
+ specifier: ^29.0.1
+ version: 29.0.1(@noble/hashes@1.8.0)
tailwindcss:
specifier: 'catalog:'
version: 4.2.2
typescript:
specifier: 'catalog:'
version: 5.9.3
+ vitest:
+ specifier: ^4.1.0
+ version: 4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
packages/hooks:
dependencies:
@@ -275,10 +296,24 @@ importers:
packages:
+ '@adobe/css-tools@4.4.4':
+ resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==}
+
'@alloc/quick-lru@5.2.0':
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@asamuzakjp/css-color@5.0.1':
+ resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/dom-selector@7.0.4':
+ resolution: {integrity: sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
+ '@asamuzakjp/nwsapi@2.3.9':
+ resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
+
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -396,6 +431,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
+ '@babel/runtime@7.29.2':
+ resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
+ engines: {node: '>=6.9.0'}
+
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -408,6 +447,46 @@ packages:
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
engines: {node: '>=6.9.0'}
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@csstools/color-helpers@6.0.2':
+ resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
+ engines: {node: '>=20.19.0'}
+
+ '@csstools/css-calc@3.1.1':
+ resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-color-parser@4.0.2':
+ resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-parser-algorithms': ^4.0.0
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0':
+ resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
+ engines: {node: '>=20.19.0'}
+ peerDependencies:
+ '@csstools/css-tokenizer': ^4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.1':
+ resolution: {integrity: sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==}
+ peerDependencies:
+ css-tree: ^3.2.1
+ peerDependenciesMeta:
+ css-tree:
+ optional: true
+
+ '@csstools/css-tokenizer@4.0.0':
+ resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
+ engines: {node: '>=20.19.0'}
+
'@dnd-kit/accessibility@3.1.1':
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
peerDependencies:
@@ -440,9 +519,24 @@ packages:
peerDependencies:
'@noble/ciphers': ^1.0.0
+ '@emnapi/core@1.9.1':
+ resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==}
+
'@emnapi/runtime@1.9.1':
resolution: {integrity: sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==}
+ '@emnapi/wasi-threads@1.2.0':
+ resolution: {integrity: sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==}
+
+ '@exodus/bytes@1.15.0':
+ resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+ peerDependencies:
+ '@noble/hashes': ^1.8.0 || ^2.0.0
+ peerDependenciesMeta:
+ '@noble/hashes':
+ optional: true
+
'@floating-ui/core@1.7.5':
resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==}
@@ -682,6 +776,9 @@ packages:
resolution: {integrity: sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==}
engines: {node: '>=18'}
+ '@napi-rs/wasm-runtime@1.1.1':
+ resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
+
'@next/env@16.2.0':
resolution: {integrity: sha512-OZIbODWWAi0epQRCRjNe1VO45LOFBzgiyqmTLzIqWq6u1wrxKnAyz1HH6tgY/Mc81YzIjRPoYsPAEr4QV4l9TA==}
@@ -770,6 +867,14 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
+ '@oxc-project/types@0.120.0':
+ resolution: {integrity: sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==}
+
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -1239,6 +1344,107 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@rolldown/binding-android-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [android]
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [darwin]
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.10':
+ resolution: {integrity: sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [darwin]
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.10':
+ resolution: {integrity: sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [freebsd]
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10':
+ resolution: {integrity: sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm]
+ os: [linux]
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
+ resolution: {integrity: sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [ppc64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [s390x]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
+ resolution: {integrity: sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [glibc]
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
+ resolution: {integrity: sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [linux]
+ libc: [musl]
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
+ resolution: {integrity: sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [openharmony]
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.10':
+ resolution: {integrity: sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==}
+ engines: {node: '>=14.0.0'}
+ cpu: [wasm32]
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10':
+ resolution: {integrity: sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [arm64]
+ os: [win32]
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10':
+ resolution: {integrity: sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ cpu: [x64]
+ os: [win32]
+
+ '@rolldown/pluginutils@1.0.0-rc.10':
+ resolution: {integrity: sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==}
+
+ '@rolldown/pluginutils@1.0.0-rc.7':
+ resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==}
+
'@sec-ant/readable-stream@0.4.1':
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
@@ -1267,6 +1473,9 @@ packages:
resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
engines: {node: '>=18'}
+ '@standard-schema/spec@1.1.0':
+ resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1362,6 +1571,35 @@ packages:
'@tailwindcss/postcss@4.2.2':
resolution: {integrity: sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==}
+ '@testing-library/dom@10.4.1':
+ resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
+ engines: {node: '>=18'}
+
+ '@testing-library/jest-dom@6.9.1':
+ resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==}
+ engines: {node: '>=14', npm: '>=6', yarn: '>=1'}
+
+ '@testing-library/react@16.3.2':
+ resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+ engines: {node: '>=18'}
+ peerDependencies:
+ '@testing-library/dom': ^10.0.0
+ '@types/react': ^19.2.0
+ '@types/react-dom': ^19.2.0
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
+ '@testing-library/user-event@14.6.1':
+ resolution: {integrity: sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==}
+ engines: {node: '>=12', npm: '>=6'}
+ peerDependencies:
+ '@testing-library/dom': '>=7.21.4'
+
'@ts-morph/common@0.27.0':
resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==}
@@ -1395,9 +1633,21 @@ packages:
cpu: [arm64]
os: [win32]
+ '@tybys/wasm-util@0.10.1':
+ resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
+
+ '@types/aria-query@5.0.4':
+ resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
+
+ '@types/chai@5.2.3':
+ resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
+
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
+ '@types/deep-eql@4.0.2':
+ resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
+
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@@ -1439,6 +1689,48 @@ packages:
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
+ '@vitejs/plugin-react@6.0.1':
+ resolution: {integrity: sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ peerDependencies:
+ '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0
+ babel-plugin-react-compiler: ^1.0.0
+ vite: ^8.0.0
+ peerDependenciesMeta:
+ '@rolldown/plugin-babel':
+ optional: true
+ babel-plugin-react-compiler:
+ optional: true
+
+ '@vitest/expect@4.1.0':
+ resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==}
+
+ '@vitest/mocker@4.1.0':
+ resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
+ '@vitest/pretty-format@4.1.0':
+ resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==}
+
+ '@vitest/runner@4.1.0':
+ resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==}
+
+ '@vitest/snapshot@4.1.0':
+ resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==}
+
+ '@vitest/spy@4.1.0':
+ resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==}
+
+ '@vitest/utils@4.1.0':
+ resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==}
+
accepts@2.0.0:
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
engines: {node: '>= 0.6'}
@@ -1470,6 +1762,10 @@ packages:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
+ ansi-styles@5.2.0:
+ resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
+ engines: {node: '>=10'}
+
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
@@ -1477,6 +1773,17 @@ packages:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
+ aria-query@5.3.0:
+ resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+
+ aria-query@5.3.2:
+ resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==}
+ engines: {node: '>= 0.4'}
+
+ assertion-error@2.0.1:
+ resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
+ engines: {node: '>=12'}
+
ast-types@0.16.1:
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
engines: {node: '>=4'}
@@ -1493,6 +1800,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
+ bidi-js@1.0.3:
+ resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
+
body-parser@2.2.2:
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
engines: {node: '>=18'}
@@ -1536,6 +1846,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
+ chai@6.2.2:
+ resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
+ engines: {node: '>=18'}
+
chalk@5.6.2:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
@@ -1645,6 +1959,13 @@ packages:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
+ css-tree@3.2.1:
+ resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
+ engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
+
+ css.escape@1.5.1:
+ resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1657,6 +1978,10 @@ packages:
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
engines: {node: '>= 12'}
+ data-urls@7.0.0:
+ resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@@ -1666,6 +1991,9 @@ packages:
supports-color:
optional: true
+ decimal.js@10.6.0:
+ resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
+
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
@@ -1715,6 +2043,12 @@ packages:
resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==}
engines: {node: '>=0.3.1'}
+ dom-accessibility-api@0.5.16:
+ resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
+
+ dom-accessibility-api@0.6.3:
+ resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
+
dotenv@17.3.1:
resolution: {integrity: sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA==}
engines: {node: '>=12'}
@@ -1747,6 +2081,10 @@ packages:
resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==}
engines: {node: '>=10.13.0'}
+ entities@6.0.1:
+ resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==}
+ engines: {node: '>=0.12'}
+
env-paths@2.2.1:
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
engines: {node: '>=6'}
@@ -1762,6 +2100,9 @@ packages:
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
engines: {node: '>= 0.4'}
+ es-module-lexer@2.0.0:
+ resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==}
+
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@@ -1781,6 +2122,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
+ estree-walker@3.0.3:
+ resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
+
etag@1.8.1:
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
engines: {node: '>= 0.6'}
@@ -1801,6 +2145,10 @@ packages:
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
engines: {node: ^18.19.0 || >=20.5.0}
+ expect-type@1.3.0:
+ resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
+ engines: {node: '>=12.0.0'}
+
express-rate-limit@8.3.1:
resolution: {integrity: sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw==}
engines: {node: '>= 16'}
@@ -1868,6 +2216,16 @@ packages:
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
engines: {node: '>=14.14'}
+ 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}
+ os: [darwin]
+
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
@@ -1949,6 +2307,10 @@ packages:
resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
engines: {node: '>=16.9.0'}
+ html-encoding-sniffer@6.0.0:
+ resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
@@ -1983,6 +2345,10 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
+ indent-string@4.0.0:
+ resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==}
+ engines: {node: '>=8'}
+
inherits@2.0.4:
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
@@ -2057,6 +2423,9 @@ packages:
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
engines: {node: '>=12'}
+ is-potential-custom-element-name@1.0.1:
+ resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
+
is-promise@4.0.0:
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
@@ -2105,6 +2474,15 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
+ jsdom@29.0.1:
+ resolution: {integrity: sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==}
+ engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
+ peerDependencies:
+ canvas: ^3.0.0
+ peerDependenciesMeta:
+ canvas:
+ optional: true
+
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -2219,6 +2597,10 @@ packages:
longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
+ lru-cache@11.2.7:
+ resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
+ engines: {node: 20 || >=22}
+
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -2227,6 +2609,10 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ lz-string@1.5.0:
+ resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
+ hasBin: true
+
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -2258,6 +2644,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ mdn-data@2.27.1:
+ resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
+
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@@ -2356,6 +2745,10 @@ packages:
resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==}
engines: {node: '>=18'}
+ min-indent@1.0.1:
+ resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
+ engines: {node: '>=4'}
+
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@@ -2448,6 +2841,9 @@ packages:
resolution: {integrity: sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A==}
engines: {node: '>= 10'}
+ obug@2.1.1:
+ resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
+
on-finished@2.4.1:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
@@ -2495,6 +2891,9 @@ packages:
resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==}
engines: {node: '>=18'}
+ parse5@8.0.0:
+ resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
+
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@@ -2516,6 +2915,9 @@ packages:
path-to-regexp@8.3.0:
resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==}
+ pathe@2.0.3:
+ resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2531,6 +2933,16 @@ packages:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss-selector-parser@7.1.1:
resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==}
engines: {node: '>=4'}
@@ -2547,6 +2959,10 @@ packages:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
+ pretty-format@27.5.1:
+ resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
+ engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
+
pretty-ms@9.3.0:
resolution: {integrity: sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==}
engines: {node: '>=18'}
@@ -2562,6 +2978,10 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
+ punycode@2.3.1:
+ resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
+ engines: {node: '>=6'}
+
qs@6.15.0:
resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==}
engines: {node: '>=0.6'}
@@ -2582,6 +3002,9 @@ packages:
peerDependencies:
react: ^19.2.3
+ react-is@17.0.2:
+ resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
+
react-markdown@10.1.0:
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
peerDependencies:
@@ -2626,6 +3049,10 @@ packages:
resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==}
engines: {node: '>= 4'}
+ redent@3.0.0:
+ resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
+ engines: {node: '>=8'}
+
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
@@ -2664,6 +3091,11 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
+ rolldown@1.0.0-rc.10:
+ resolution: {integrity: sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+
router@2.2.0:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
@@ -2678,6 +3110,10 @@ packages:
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+ saxes@6.0.0:
+ resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
+ engines: {node: '>=v12.22.7'}
+
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@@ -2736,6 +3172,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
+ siginfo@2.0.0:
+ resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+
signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@@ -2763,10 +3202,16 @@ packages:
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
+ stackback@0.0.2:
+ resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
+
statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
+ std-env@4.0.0:
+ resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
+
stdin-discarder@0.2.2:
resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==}
engines: {node: '>=18'}
@@ -2809,6 +3254,10 @@ packages:
resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==}
engines: {node: '>=18'}
+ strip-indent@3.0.0:
+ resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
+ engines: {node: '>=8'}
+
style-to-js@1.1.21:
resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==}
@@ -2828,6 +3277,9 @@ packages:
babel-plugin-macros:
optional: true
+ symbol-tree@3.2.4:
+ resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
+
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
@@ -2845,6 +3297,21 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
+ tinybench@2.9.0:
+ resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+
+ tinyexec@1.0.4:
+ resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
+ engines: {node: '>=18'}
+
+ tinyglobby@0.2.15:
+ resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
+ engines: {node: '>=12.0.0'}
+
+ tinyrainbow@3.1.0:
+ resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
+ engines: {node: '>=14.0.0'}
+
tldts-core@7.0.27:
resolution: {integrity: sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==}
@@ -2864,6 +3331,10 @@ packages:
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
engines: {node: '>=16'}
+ tr46@6.0.0:
+ resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
+ engines: {node: '>=20'}
+
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -2903,6 +3374,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
+ undici@7.24.5:
+ resolution: {integrity: sha512-3IWdCpjgxp15CbJnsi/Y9TCDE7HWVN19j1hmzVhoAkY/+CJx449tVxT5wZc1Gwg8J+P0LWvzlBzxYRnHJ+1i7Q==}
+ engines: {node: '>=20.18.1'}
+
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
@@ -2979,10 +3454,104 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ vite@8.0.1:
+ resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==}
+ engines: {node: ^20.19.0 || >=22.12.0}
+ hasBin: true
+ peerDependencies:
+ '@types/node': ^20.19.0 || >=22.12.0
+ '@vitejs/devtools': ^0.1.0
+ esbuild: ^0.27.0
+ jiti: '>=1.21.0'
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: '>=0.54.8'
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ peerDependenciesMeta:
+ '@types/node':
+ optional: true
+ '@vitejs/devtools':
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+
+ vitest@4.1.0:
+ resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==}
+ engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@opentelemetry/api': ^1.9.0
+ '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
+ '@vitest/browser-playwright': 4.1.0
+ '@vitest/browser-preview': 4.1.0
+ '@vitest/browser-webdriverio': 4.1.0
+ '@vitest/ui': 4.1.0
+ happy-dom: '*'
+ jsdom: '*'
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@opentelemetry/api':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser-playwright':
+ optional: true
+ '@vitest/browser-preview':
+ optional: true
+ '@vitest/browser-webdriverio':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
+ w3c-xmlserializer@5.0.0:
+ resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
+ engines: {node: '>=18'}
+
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
+ webidl-conversions@8.0.1:
+ resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
+ engines: {node: '>=20'}
+
+ whatwg-mimetype@5.0.0:
+ resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
+ engines: {node: '>=20'}
+
+ whatwg-url@16.0.1:
+ resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
+ engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
+
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -2993,6 +3562,11 @@ packages:
engines: {node: ^16.13.0 || >=18.0.0}
hasBin: true
+ why-is-node-running@2.3.0:
+ resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
+ engines: {node: '>=8'}
+ hasBin: true
+
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -3008,6 +3582,13 @@ packages:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
+ xml-name-validator@5.0.0:
+ resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
+ engines: {node: '>=18'}
+
+ xmlchars@2.2.0:
+ resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3062,8 +3643,28 @@ packages:
snapshots:
+ '@adobe/css-tools@4.4.4': {}
+
'@alloc/quick-lru@5.2.0': {}
+ '@asamuzakjp/css-color@5.0.1':
+ dependencies:
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+ lru-cache: 11.2.7
+
+ '@asamuzakjp/dom-selector@7.0.4':
+ dependencies:
+ '@asamuzakjp/nwsapi': 2.3.9
+ bidi-js: 1.0.3
+ css-tree: 3.2.1
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.7
+
+ '@asamuzakjp/nwsapi@2.3.9': {}
+
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -3227,6 +3828,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@babel/runtime@7.29.2': {}
+
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -3250,6 +3853,34 @@ snapshots:
'@babel/helper-string-parser': 7.27.1
'@babel/helper-validator-identifier': 7.28.5
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@csstools/color-helpers@6.0.2': {}
+
+ '@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/color-helpers': 6.0.2
+ '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
+ dependencies:
+ '@csstools/css-tokenizer': 4.0.0
+
+ '@csstools/css-syntax-patches-for-csstree@1.1.1(css-tree@3.2.1)':
+ optionalDependencies:
+ css-tree: 3.2.1
+
+ '@csstools/css-tokenizer@4.0.0': {}
+
'@dnd-kit/accessibility@3.1.1(react@19.2.3)':
dependencies:
react: 19.2.3
@@ -3291,11 +3922,26 @@ snapshots:
dependencies:
'@noble/ciphers': 1.3.0
+ '@emnapi/core@1.9.1':
+ dependencies:
+ '@emnapi/wasi-threads': 1.2.0
+ tslib: 2.8.1
+ optional: true
+
'@emnapi/runtime@1.9.1':
dependencies:
tslib: 2.8.1
optional: true
+ '@emnapi/wasi-threads@1.2.0':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@exodus/bytes@1.15.0(@noble/hashes@1.8.0)':
+ optionalDependencies:
+ '@noble/hashes': 1.8.0
+
'@floating-ui/core@1.7.5':
dependencies:
'@floating-ui/utils': 0.2.11
@@ -3492,6 +4138,13 @@ snapshots:
outvariant: 1.4.3
strict-event-emitter: 0.5.1
+ '@napi-rs/wasm-runtime@1.1.1':
+ dependencies:
+ '@emnapi/core': 1.9.1
+ '@emnapi/runtime': 1.9.1
+ '@tybys/wasm-util': 0.10.1
+ optional: true
+
'@next/env@16.2.0': {}
'@next/swc-darwin-arm64@16.2.0':
@@ -3547,6 +4200,12 @@ snapshots:
'@open-draft/until@2.1.0': {}
+ '@oxc-project/types@0.120.0': {}
+
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -4026,6 +4685,57 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@rolldown/binding-android-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-darwin-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-darwin-x64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-freebsd-x64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-arm64-musl@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-gnu@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-linux-x64-musl@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-openharmony-arm64@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-wasm32-wasi@1.0.0-rc.10':
+ dependencies:
+ '@napi-rs/wasm-runtime': 1.1.1
+ optional: true
+
+ '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/binding-win32-x64-msvc@1.0.0-rc.10':
+ optional: true
+
+ '@rolldown/pluginutils@1.0.0-rc.10': {}
+
+ '@rolldown/pluginutils@1.0.0-rc.7': {}
+
'@sec-ant/readable-stream@0.4.1': {}
'@shikijs/core@3.23.0':
@@ -4063,6 +4773,8 @@ snapshots:
'@sindresorhus/merge-streams@4.0.0': {}
+ '@standard-schema/spec@1.1.0': {}
+
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -4136,6 +4848,40 @@ snapshots:
postcss: 8.5.8
tailwindcss: 4.2.2
+ '@testing-library/dom@10.4.1':
+ dependencies:
+ '@babel/code-frame': 7.29.0
+ '@babel/runtime': 7.29.2
+ '@types/aria-query': 5.0.4
+ aria-query: 5.3.0
+ dom-accessibility-api: 0.5.16
+ lz-string: 1.5.0
+ picocolors: 1.1.1
+ pretty-format: 27.5.1
+
+ '@testing-library/jest-dom@6.9.1':
+ dependencies:
+ '@adobe/css-tools': 4.4.4
+ aria-query: 5.3.2
+ css.escape: 1.5.1
+ dom-accessibility-api: 0.6.3
+ picocolors: 1.1.1
+ redent: 3.0.0
+
+ '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
+ dependencies:
+ '@babel/runtime': 7.29.2
+ '@testing-library/dom': 10.4.1
+ react: 19.2.3
+ react-dom: 19.2.3(react@19.2.3)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
+ '@testing-library/user-event@14.6.1(@testing-library/dom@10.4.1)':
+ dependencies:
+ '@testing-library/dom': 10.4.1
+
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
@@ -4160,10 +4906,24 @@ snapshots:
'@turbo/windows-arm64@2.8.20':
optional: true
+ '@tybys/wasm-util@0.10.1':
+ dependencies:
+ tslib: 2.8.1
+ optional: true
+
+ '@types/aria-query@5.0.4': {}
+
+ '@types/chai@5.2.3':
+ dependencies:
+ '@types/deep-eql': 4.0.2
+ assertion-error: 2.0.1
+
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
+ '@types/deep-eql@4.0.2': {}
+
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.8
@@ -4202,6 +4962,53 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
+ '@vitejs/plugin-react@6.0.1(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))':
+ dependencies:
+ '@rolldown/pluginutils': 1.0.0-rc.7
+ vite: 8.0.1(@types/node@25.5.0)(jiti@2.6.1)
+
+ '@vitest/expect@4.1.0':
+ dependencies:
+ '@standard-schema/spec': 1.1.0
+ '@types/chai': 5.2.3
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ chai: 6.2.2
+ tinyrainbow: 3.1.0
+
+ '@vitest/mocker@4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))':
+ dependencies:
+ '@vitest/spy': 4.1.0
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ msw: 2.12.14(@types/node@25.5.0)(typescript@5.9.3)
+ vite: 8.0.1(@types/node@25.5.0)(jiti@2.6.1)
+
+ '@vitest/pretty-format@4.1.0':
+ dependencies:
+ tinyrainbow: 3.1.0
+
+ '@vitest/runner@4.1.0':
+ dependencies:
+ '@vitest/utils': 4.1.0
+ pathe: 2.0.3
+
+ '@vitest/snapshot@4.1.0':
+ dependencies:
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/utils': 4.1.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
+ '@vitest/spy@4.1.0': {}
+
+ '@vitest/utils@4.1.0':
+ dependencies:
+ '@vitest/pretty-format': 4.1.0
+ convert-source-map: 2.0.0
+ tinyrainbow: 3.1.0
+
accepts@2.0.0:
dependencies:
mime-types: 3.0.2
@@ -4228,12 +5035,22 @@ snapshots:
dependencies:
color-convert: 2.0.1
+ ansi-styles@5.2.0: {}
+
argparse@2.0.1: {}
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
+ aria-query@5.3.0:
+ dependencies:
+ dequal: 2.0.3
+
+ aria-query@5.3.2: {}
+
+ assertion-error@2.0.1: {}
+
ast-types@0.16.1:
dependencies:
tslib: 2.8.1
@@ -4244,6 +5061,10 @@ snapshots:
baseline-browser-mapping@2.10.9: {}
+ bidi-js@1.0.3:
+ dependencies:
+ require-from-string: 2.0.2
+
body-parser@2.2.2:
dependencies:
bytes: 3.1.2
@@ -4296,6 +5117,8 @@ snapshots:
ccount@2.0.1: {}
+ chai@6.2.2: {}
+
chalk@5.6.2: {}
character-entities-html4@2.1.0: {}
@@ -4386,16 +5209,32 @@ snapshots:
shebang-command: 2.0.0
which: 2.0.2
+ css-tree@3.2.1:
+ dependencies:
+ mdn-data: 2.27.1
+ source-map-js: 1.2.1
+
+ css.escape@1.5.1: {}
+
cssesc@3.0.0: {}
csstype@3.2.3: {}
data-uri-to-buffer@4.0.1: {}
+ data-urls@7.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
debug@4.4.3:
dependencies:
ms: 2.1.3
+ decimal.js@10.6.0: {}
+
decode-named-character-reference@1.3.0:
dependencies:
character-entities: 2.0.2
@@ -4427,6 +5266,10 @@ snapshots:
diff@8.0.3: {}
+ dom-accessibility-api@0.5.16: {}
+
+ dom-accessibility-api@0.6.3: {}
+
dotenv@17.3.1: {}
dunder-proto@1.0.1:
@@ -4457,6 +5300,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.0
+ entities@6.0.1: {}
+
env-paths@2.2.1: {}
error-ex@1.3.4:
@@ -4467,6 +5312,8 @@ snapshots:
es-errors@1.3.0: {}
+ es-module-lexer@2.0.0: {}
+
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@@ -4479,6 +5326,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
+ estree-walker@3.0.3:
+ dependencies:
+ '@types/estree': 1.0.8
+
etag@1.8.1: {}
eventsource-parser@3.0.6: {}
@@ -4514,6 +5365,8 @@ snapshots:
strip-final-newline: 4.0.0
yoctocolors: 2.1.2
+ expect-type@1.3.0: {}
+
express-rate-limit@8.3.1(express@5.2.1):
dependencies:
express: 5.2.1
@@ -4612,6 +5465,12 @@ snapshots:
jsonfile: 6.2.0
universalify: 2.0.1
+ fsevents@2.3.2:
+ optional: true
+
+ fsevents@2.3.3:
+ optional: true
+
function-bind@1.1.2: {}
fuzzysort@3.1.0: {}
@@ -4709,6 +5568,12 @@ snapshots:
hono@4.12.8: {}
+ html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
html-url-attributes@3.0.1: {}
html-void-elements@3.0.0: {}
@@ -4743,6 +5608,8 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
+ indent-string@4.0.0: {}
+
inherits@2.0.4: {}
inline-style-parser@0.2.7: {}
@@ -4790,6 +5657,8 @@ snapshots:
is-plain-obj@4.1.0: {}
+ is-potential-custom-element-name@1.0.1: {}
+
is-promise@4.0.0: {}
is-regexp@3.1.0: {}
@@ -4820,6 +5689,32 @@ snapshots:
dependencies:
argparse: 2.0.1
+ jsdom@29.0.1(@noble/hashes@1.8.0):
+ dependencies:
+ '@asamuzakjp/css-color': 5.0.1
+ '@asamuzakjp/dom-selector': 7.0.4
+ '@bramus/specificity': 2.4.2
+ '@csstools/css-syntax-patches-for-csstree': 1.1.1(css-tree@3.2.1)
+ '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
+ css-tree: 3.2.1
+ data-urls: 7.0.0(@noble/hashes@1.8.0)
+ decimal.js: 10.6.0
+ html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
+ is-potential-custom-element-name: 1.0.1
+ lru-cache: 11.2.7
+ parse5: 8.0.0
+ saxes: 6.0.0
+ symbol-tree: 3.2.4
+ tough-cookie: 6.0.1
+ undici: 7.24.5
+ w3c-xmlserializer: 5.0.0
+ webidl-conversions: 8.0.1
+ whatwg-mimetype: 5.0.0
+ whatwg-url: 16.0.1(@noble/hashes@1.8.0)
+ xml-name-validator: 5.0.0
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
jsesc@3.1.0: {}
json-parse-even-better-errors@2.3.1: {}
@@ -4898,6 +5793,8 @@ snapshots:
longest-streak@3.1.0: {}
+ lru-cache@11.2.7: {}
+
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@@ -4906,6 +5803,8 @@ snapshots:
dependencies:
react: 19.2.3
+ lz-string@1.5.0: {}
+
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
@@ -5001,6 +5900,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ mdn-data@2.27.1: {}
+
media-typer@1.1.0: {}
merge-descriptors@2.0.0: {}
@@ -5157,6 +6058,8 @@ snapshots:
mimic-function@5.0.1: {}
+ min-indent@1.0.1: {}
+
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@@ -5201,7 +6104,7 @@ snapshots:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
- next@16.2.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
+ next@16.2.0(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.2.0
'@swc/helpers': 0.5.15
@@ -5220,6 +6123,7 @@ snapshots:
'@next/swc-linux-x64-musl': 16.2.0
'@next/swc-win32-arm64-msvc': 16.2.0
'@next/swc-win32-x64-msvc': 16.2.0
+ '@playwright/test': 1.58.2
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -5250,6 +6154,8 @@ snapshots:
object-treeify@1.1.33: {}
+ obug@2.1.1: {}
+
on-finished@2.4.1:
dependencies:
ee-first: 1.1.1
@@ -5320,6 +6226,10 @@ snapshots:
parse-ms@4.0.0: {}
+ parse5@8.0.0:
+ dependencies:
+ entities: 6.0.1
+
parseurl@1.3.3: {}
path-browserify@1.0.1: {}
@@ -5332,6 +6242,8 @@ snapshots:
path-to-regexp@8.3.0: {}
+ pathe@2.0.3: {}
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -5340,6 +6252,14 @@ snapshots:
pkce-challenge@5.0.1: {}
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss-selector-parser@7.1.1:
dependencies:
cssesc: 3.0.0
@@ -5359,6 +6279,12 @@ snapshots:
powershell-utils@0.1.0: {}
+ pretty-format@27.5.1:
+ dependencies:
+ ansi-regex: 5.0.1
+ ansi-styles: 5.2.0
+ react-is: 17.0.2
+
pretty-ms@9.3.0:
dependencies:
parse-ms: 4.0.0
@@ -5375,6 +6301,8 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
+ punycode@2.3.1: {}
+
qs@6.15.0:
dependencies:
side-channel: 1.1.0
@@ -5395,6 +6323,8 @@ snapshots:
react: 19.2.3
scheduler: 0.27.0
+ react-is@17.0.2: {}
+
react-markdown@10.1.0(@types/react@19.2.14)(react@19.2.3):
dependencies:
'@types/hast': 3.0.4
@@ -5450,6 +6380,11 @@ snapshots:
tiny-invariant: 1.3.3
tslib: 2.8.1
+ redent@3.0.0:
+ dependencies:
+ indent-string: 4.0.0
+ strip-indent: 3.0.0
+
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
@@ -5492,6 +6427,27 @@ snapshots:
reusify@1.1.0: {}
+ rolldown@1.0.0-rc.10:
+ dependencies:
+ '@oxc-project/types': 0.120.0
+ '@rolldown/pluginutils': 1.0.0-rc.10
+ optionalDependencies:
+ '@rolldown/binding-android-arm64': 1.0.0-rc.10
+ '@rolldown/binding-darwin-arm64': 1.0.0-rc.10
+ '@rolldown/binding-darwin-x64': 1.0.0-rc.10
+ '@rolldown/binding-freebsd-x64': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-arm64-musl': 1.0.0-rc.10
+ '@rolldown/binding-linux-ppc64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-s390x-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-x64-gnu': 1.0.0-rc.10
+ '@rolldown/binding-linux-x64-musl': 1.0.0-rc.10
+ '@rolldown/binding-openharmony-arm64': 1.0.0-rc.10
+ '@rolldown/binding-wasm32-wasi': 1.0.0-rc.10
+ '@rolldown/binding-win32-arm64-msvc': 1.0.0-rc.10
+ '@rolldown/binding-win32-x64-msvc': 1.0.0-rc.10
+
router@2.2.0:
dependencies:
debug: 4.4.3
@@ -5510,6 +6466,10 @@ snapshots:
safer-buffer@2.1.2: {}
+ saxes@6.0.0:
+ dependencies:
+ xmlchars: 2.2.0
+
scheduler@0.27.0: {}
semver@6.3.1: {}
@@ -5664,6 +6624,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
+ siginfo@2.0.0: {}
+
signal-exit@3.0.7: {}
signal-exit@4.1.0: {}
@@ -5681,8 +6643,12 @@ snapshots:
space-separated-tokens@2.0.2: {}
+ stackback@0.0.2: {}
+
statuses@2.0.2: {}
+ std-env@4.0.0: {}
+
stdin-discarder@0.2.2: {}
strict-event-emitter@0.5.1: {}
@@ -5724,6 +6690,10 @@ snapshots:
strip-final-newline@4.0.0: {}
+ strip-indent@3.0.0:
+ dependencies:
+ min-indent: 1.0.1
+
style-to-js@1.1.21:
dependencies:
style-to-object: 1.0.14
@@ -5737,6 +6707,8 @@ snapshots:
client-only: 0.0.1
react: 19.2.3
+ symbol-tree@3.2.4: {}
+
tagged-tag@1.0.0: {}
tailwind-merge@3.5.0: {}
@@ -5747,6 +6719,17 @@ snapshots:
tiny-invariant@1.3.3: {}
+ tinybench@2.9.0: {}
+
+ tinyexec@1.0.4: {}
+
+ tinyglobby@0.2.15:
+ dependencies:
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+
+ tinyrainbow@3.1.0: {}
+
tldts-core@7.0.27: {}
tldts@7.0.27:
@@ -5763,6 +6746,10 @@ snapshots:
dependencies:
tldts: 7.0.27
+ tr46@6.0.0:
+ dependencies:
+ punycode: 2.3.1
+
trim-lines@3.0.1: {}
trough@2.2.0: {}
@@ -5805,6 +6792,8 @@ snapshots:
undici-types@7.18.2: {}
+ undici@7.24.5: {}
+
unicorn-magic@0.3.0: {}
unified@11.0.5:
@@ -5883,8 +6872,64 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1):
+ dependencies:
+ lightningcss: 1.32.0
+ picomatch: 4.0.3
+ postcss: 8.5.8
+ rolldown: 1.0.0-rc.10
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 25.5.0
+ fsevents: 2.3.3
+ jiti: 2.6.1
+
+ vitest@4.1.0(@types/node@25.5.0)(jsdom@29.0.1(@noble/hashes@1.8.0))(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1)):
+ dependencies:
+ '@vitest/expect': 4.1.0
+ '@vitest/mocker': 4.1.0(msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3))(vite@8.0.1(@types/node@25.5.0)(jiti@2.6.1))
+ '@vitest/pretty-format': 4.1.0
+ '@vitest/runner': 4.1.0
+ '@vitest/snapshot': 4.1.0
+ '@vitest/spy': 4.1.0
+ '@vitest/utils': 4.1.0
+ es-module-lexer: 2.0.0
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ obug: 2.1.1
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 4.0.0
+ tinybench: 2.9.0
+ tinyexec: 1.0.4
+ tinyglobby: 0.2.15
+ tinyrainbow: 3.1.0
+ vite: 8.0.1(@types/node@25.5.0)(jiti@2.6.1)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/node': 25.5.0
+ jsdom: 29.0.1(@noble/hashes@1.8.0)
+ transitivePeerDependencies:
+ - msw
+
+ w3c-xmlserializer@5.0.0:
+ dependencies:
+ xml-name-validator: 5.0.0
+
web-streams-polyfill@3.3.3: {}
+ webidl-conversions@8.0.1: {}
+
+ whatwg-mimetype@5.0.0: {}
+
+ whatwg-url@16.0.1(@noble/hashes@1.8.0):
+ dependencies:
+ '@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
+ tr46: 6.0.0
+ webidl-conversions: 8.0.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -5893,6 +6938,11 @@ snapshots:
dependencies:
isexe: 3.1.5
+ why-is-node-running@2.3.0:
+ dependencies:
+ siginfo: 2.0.0
+ stackback: 0.0.2
+
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@@ -5912,6 +6962,10 @@ snapshots:
is-wsl: 3.1.1
powershell-utils: 0.1.0
+ xml-name-validator@5.0.0: {}
+
+ xmlchars@2.2.0: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
diff --git a/server/cmd/server/integration_test.go b/server/cmd/server/integration_test.go
new file mode 100644
index 00000000..54a59c01
--- /dev/null
+++ b/server/cmd/server/integration_test.go
@@ -0,0 +1,618 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/gorilla/websocket"
+ "github.com/jackc/pgx/v5/pgxpool"
+
+ "github.com/multica-ai/multica/server/internal/realtime"
+ db "github.com/multica-ai/multica/server/pkg/db/generated"
+)
+
+var (
+ testServer *httptest.Server
+ testToken string
+ testUserID string
+ testWorkspaceID string
+)
+
+var jwtSecret = []byte("multica-dev-secret-change-in-production")
+
+func TestMain(m *testing.M) {
+ dbURL := os.Getenv("DATABASE_URL")
+ if dbURL == "" {
+ dbURL = "postgres://multica:multica@localhost:5432/multica?sslmode=disable"
+ }
+
+ pool, err := pgxpool.New(context.Background(), dbURL)
+ if err != nil {
+ fmt.Printf("Skipping integration tests: could not connect to database: %v\n", err)
+ os.Exit(0)
+ }
+ defer pool.Close()
+
+ // Get seed data IDs
+ row := pool.QueryRow(context.Background(), `SELECT id FROM "user" WHERE email = 'jiayuan@multica.ai'`)
+ row.Scan(&testUserID)
+
+ row = pool.QueryRow(context.Background(), `SELECT id FROM workspace WHERE slug = 'multica'`)
+ row.Scan(&testWorkspaceID)
+
+ if testUserID == "" || testWorkspaceID == "" {
+ fmt.Println("Skipping integration tests: seed data not found. Run 'go run ./cmd/seed/' first.")
+ os.Exit(0)
+ }
+
+ queries := db.New(pool)
+ hub := realtime.NewHub()
+ go hub.Run()
+
+ router := NewRouter(queries, hub)
+ testServer = httptest.NewServer(router)
+ defer testServer.Close()
+
+ // Login to get a real JWT token
+ loginBody, _ := json.Marshal(map[string]string{
+ "email": "jiayuan@multica.ai",
+ "name": "Jiayuan Zhang",
+ })
+ resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(loginBody))
+ if err != nil {
+ fmt.Printf("Skipping: login failed: %v\n", err)
+ os.Exit(0)
+ }
+ defer resp.Body.Close()
+
+ var loginResp struct {
+ Token string `json:"token"`
+ User struct {
+ ID string `json:"id"`
+ } `json:"user"`
+ }
+ json.NewDecoder(resp.Body).Decode(&loginResp)
+ testToken = loginResp.Token
+
+ os.Exit(m.Run())
+}
+
+// Helper to make authenticated requests
+func authRequest(t *testing.T, method, path string, body any) *http.Response {
+ t.Helper()
+ var bodyReader io.Reader
+ if body != nil {
+ b, _ := json.Marshal(body)
+ bodyReader = bytes.NewReader(b)
+ }
+ req, err := http.NewRequest(method, testServer.URL+path, bodyReader)
+ if err != nil {
+ t.Fatalf("failed to create request: %v", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+testToken)
+ req.Header.Set("X-Workspace-ID", testWorkspaceID)
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ return resp
+}
+
+func readJSON(t *testing.T, resp *http.Response, v any) {
+ t.Helper()
+ defer resp.Body.Close()
+ if err := json.NewDecoder(resp.Body).Decode(v); err != nil {
+ t.Fatalf("failed to decode response: %v", err)
+ }
+}
+
+// ---- Health ----
+
+func TestHealth(t *testing.T) {
+ resp, err := http.Get(testServer.URL + "/health")
+ if err != nil {
+ t.Fatalf("health check failed: %v", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var result map[string]string
+ json.NewDecoder(resp.Body).Decode(&result)
+ if result["status"] != "ok" {
+ t.Fatalf("expected status ok, got %s", result["status"])
+ }
+}
+
+// ---- Auth ----
+
+func TestLoginAndGetMe(t *testing.T) {
+ // Login
+ body, _ := json.Marshal(map[string]string{
+ "email": "integration-test@multica.ai",
+ "name": "Integration Tester",
+ })
+ resp, err := http.Post(testServer.URL+"/auth/login", "application/json", bytes.NewReader(body))
+ if err != nil {
+ t.Fatalf("login failed: %v", err)
+ }
+
+ if resp.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", resp.StatusCode)
+ }
+
+ var loginResp struct {
+ Token string `json:"token"`
+ User struct {
+ ID string `json:"id"`
+ Email string `json:"email"`
+ Name string `json:"name"`
+ } `json:"user"`
+ }
+ readJSON(t, resp, &loginResp)
+
+ if loginResp.Token == "" {
+ t.Fatal("expected non-empty token")
+ }
+ if loginResp.User.Email != "integration-test@multica.ai" {
+ t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", loginResp.User.Email)
+ }
+
+ // Use token to call /api/me
+ req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+loginResp.Token)
+ meResp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("getMe failed: %v", err)
+ }
+
+ if meResp.StatusCode != 200 {
+ t.Fatalf("expected 200, got %d", meResp.StatusCode)
+ }
+
+ var me struct {
+ Email string `json:"email"`
+ Name string `json:"name"`
+ }
+ readJSON(t, meResp, &me)
+ if me.Email != "integration-test@multica.ai" {
+ t.Fatalf("expected email 'integration-test@multica.ai', got '%s'", me.Email)
+ }
+}
+
+func TestProtectedRoutesRequireAuth(t *testing.T) {
+ paths := []string{"/api/me", "/api/issues", "/api/agents", "/api/inbox", "/api/workspaces"}
+
+ for _, path := range paths {
+ resp, err := http.Get(testServer.URL + path)
+ if err != nil {
+ t.Fatalf("request to %s failed: %v", path, err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 401 {
+ t.Fatalf("%s: expected 401, got %d", path, resp.StatusCode)
+ }
+ }
+}
+
+func TestInvalidJWT(t *testing.T) {
+ cases := []struct {
+ name string
+ token string
+ }{
+ {"garbage token", "not-a-jwt"},
+ {"empty token", ""},
+ {"wrong secret", func() string {
+ claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(time.Hour).Unix()}
+ t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte("wrong"))
+ return t
+ }()},
+ {"expired token", func() string {
+ claims := jwt.MapClaims{"sub": "test", "exp": time.Now().Add(-time.Hour).Unix()}
+ t, _ := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(jwtSecret)
+ return t
+ }()},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ req, _ := http.NewRequest("GET", testServer.URL+"/api/me", nil)
+ if tc.token != "" {
+ req.Header.Set("Authorization", "Bearer "+tc.token)
+ }
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("request failed: %v", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 401 {
+ t.Fatalf("expected 401, got %d", resp.StatusCode)
+ }
+ })
+ }
+}
+
+// ---- Issues CRUD through full router ----
+
+func TestIssuesCRUDThroughRouter(t *testing.T) {
+ // Create
+ resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Integration test issue",
+ "status": "todo",
+ "priority": "high",
+ })
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ t.Fatalf("CreateIssue: expected 201, got %d: %s", resp.StatusCode, body)
+ }
+
+ var created map[string]any
+ readJSON(t, resp, &created)
+ issueID := created["id"].(string)
+ if created["title"] != "Integration test issue" {
+ t.Fatalf("expected title 'Integration test issue', got '%s'", created["title"])
+ }
+
+ // Get
+ resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("GetIssue: expected 200, got %d", resp.StatusCode)
+ }
+ var fetched map[string]any
+ readJSON(t, resp, &fetched)
+ if fetched["id"] != issueID {
+ t.Fatalf("expected id %s, got %s", issueID, fetched["id"])
+ }
+
+ // Update status only — should preserve title
+ resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
+ "status": "in_progress",
+ })
+ if resp.StatusCode != 200 {
+ t.Fatalf("UpdateIssue: expected 200, got %d", resp.StatusCode)
+ }
+ var updated map[string]any
+ readJSON(t, resp, &updated)
+ if updated["status"] != "in_progress" {
+ t.Fatalf("expected status 'in_progress', got '%s'", updated["status"])
+ }
+ if updated["title"] != "Integration test issue" {
+ t.Fatalf("title should be preserved, got '%s'", updated["title"])
+ }
+
+ // Update title only — should preserve status
+ resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
+ "title": "Renamed integration issue",
+ })
+ if resp.StatusCode != 200 {
+ t.Fatalf("UpdateIssue title: expected 200, got %d", resp.StatusCode)
+ }
+ var updated2 map[string]any
+ readJSON(t, resp, &updated2)
+ if updated2["title"] != "Renamed integration issue" {
+ t.Fatalf("expected title 'Renamed integration issue', got '%s'", updated2["title"])
+ }
+ if updated2["status"] != "in_progress" {
+ t.Fatalf("status should be preserved, got '%s'", updated2["status"])
+ }
+
+ // List
+ resp = authRequest(t, "GET", "/api/issues?workspace_id="+testWorkspaceID, nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListIssues: expected 200, got %d", resp.StatusCode)
+ }
+ var listResp map[string]any
+ readJSON(t, resp, &listResp)
+ total := listResp["total"].(float64)
+ if total < 1 {
+ t.Fatal("expected at least 1 issue")
+ }
+
+ // Delete
+ resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
+ resp.Body.Close()
+ if resp.StatusCode != 204 {
+ t.Fatalf("DeleteIssue: expected 204, got %d", resp.StatusCode)
+ }
+
+ // Verify deleted
+ resp = authRequest(t, "GET", "/api/issues/"+issueID, nil)
+ resp.Body.Close()
+ if resp.StatusCode != 404 {
+ t.Fatalf("GetIssue after delete: expected 404, got %d", resp.StatusCode)
+ }
+}
+
+// ---- Comments through full router ----
+
+func TestCommentsThroughRouter(t *testing.T) {
+ // Create issue
+ resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "Comment integration test",
+ })
+ var issue map[string]any
+ readJSON(t, resp, &issue)
+ issueID := issue["id"].(string)
+
+ // Create comment
+ resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
+ "content": "Integration test comment",
+ "type": "comment",
+ })
+ if resp.StatusCode != 201 {
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ t.Fatalf("CreateComment: expected 201, got %d: %s", resp.StatusCode, body)
+ }
+ var comment map[string]any
+ readJSON(t, resp, &comment)
+ if comment["content"] != "Integration test comment" {
+ t.Fatalf("expected content 'Integration test comment', got '%s'", comment["content"])
+ }
+
+ // Create second comment
+ resp = authRequest(t, "POST", "/api/issues/"+issueID+"/comments", map[string]any{
+ "content": "Second comment",
+ "type": "comment",
+ })
+ resp.Body.Close()
+
+ // List comments
+ resp = authRequest(t, "GET", "/api/issues/"+issueID+"/comments", nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListComments: expected 200, got %d", resp.StatusCode)
+ }
+ var comments []map[string]any
+ readJSON(t, resp, &comments)
+ if len(comments) != 2 {
+ t.Fatalf("expected 2 comments, got %d", len(comments))
+ }
+
+ // Cleanup
+ resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
+ resp.Body.Close()
+}
+
+// ---- Agents through full router ----
+
+func TestAgentsThroughRouter(t *testing.T) {
+ // List
+ resp := authRequest(t, "GET", "/api/agents?workspace_id="+testWorkspaceID, nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListAgents: expected 200, got %d", resp.StatusCode)
+ }
+ var agents []map[string]any
+ readJSON(t, resp, &agents)
+ if len(agents) < 1 {
+ t.Fatal("expected at least 1 agent")
+ }
+
+ // Get
+ agentID := agents[0]["id"].(string)
+ resp = authRequest(t, "GET", "/api/agents/"+agentID, nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("GetAgent: expected 200, got %d", resp.StatusCode)
+ }
+ var agent map[string]any
+ readJSON(t, resp, &agent)
+ if agent["id"] != agentID {
+ t.Fatalf("expected agent id %s, got %s", agentID, agent["id"])
+ }
+
+ // Update status
+ resp = authRequest(t, "PUT", "/api/agents/"+agentID, map[string]any{
+ "status": "idle",
+ })
+ if resp.StatusCode != 200 {
+ t.Fatalf("UpdateAgent: expected 200, got %d", resp.StatusCode)
+ }
+ var updated map[string]any
+ readJSON(t, resp, &updated)
+ if updated["status"] != "idle" {
+ t.Fatalf("expected status 'idle', got '%s'", updated["status"])
+ }
+ // Name should be preserved
+ if updated["name"] != agents[0]["name"] {
+ t.Fatalf("name should be preserved, got '%s'", updated["name"])
+ }
+}
+
+// ---- Workspaces through full router ----
+
+func TestWorkspacesThroughRouter(t *testing.T) {
+ // List
+ resp := authRequest(t, "GET", "/api/workspaces", nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListWorkspaces: expected 200, got %d", resp.StatusCode)
+ }
+ var workspaces []map[string]any
+ readJSON(t, resp, &workspaces)
+ if len(workspaces) < 1 {
+ t.Fatal("expected at least 1 workspace")
+ }
+
+ // Get
+ wsID := workspaces[0]["id"].(string)
+ resp = authRequest(t, "GET", "/api/workspaces/"+wsID, nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("GetWorkspace: expected 200, got %d", resp.StatusCode)
+ }
+ var ws map[string]any
+ readJSON(t, resp, &ws)
+ if ws["id"] != wsID {
+ t.Fatalf("expected workspace id %s, got %s", wsID, ws["id"])
+ }
+
+ // Update
+ resp = authRequest(t, "PUT", "/api/workspaces/"+wsID, map[string]any{
+ "description": "Integration test update",
+ })
+ if resp.StatusCode != 200 {
+ t.Fatalf("UpdateWorkspace: expected 200, got %d", resp.StatusCode)
+ }
+ var updated map[string]any
+ readJSON(t, resp, &updated)
+ if updated["description"] != "Integration test update" {
+ t.Fatalf("expected description 'Integration test update', got '%v'", updated["description"])
+ }
+ // Name should be preserved
+ if updated["name"] != ws["name"] {
+ t.Fatalf("name should be preserved")
+ }
+
+ // Members
+ resp = authRequest(t, "GET", "/api/workspaces/"+wsID+"/members", nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListMembers: expected 200, got %d", resp.StatusCode)
+ }
+ var members []map[string]any
+ readJSON(t, resp, &members)
+ if len(members) < 1 {
+ t.Fatal("expected at least 1 member")
+ }
+ // Verify member has user info
+ if members[0]["email"] == nil || members[0]["email"] == "" {
+ t.Fatal("member should have email field")
+ }
+ if members[0]["role"] == nil || members[0]["role"] == "" {
+ t.Fatal("member should have role field")
+ }
+}
+
+// ---- Inbox through full router ----
+
+func TestInboxThroughRouter(t *testing.T) {
+ resp := authRequest(t, "GET", "/api/inbox", nil)
+ if resp.StatusCode != 200 {
+ t.Fatalf("ListInbox: expected 200, got %d", resp.StatusCode)
+ }
+ var items []map[string]any
+ readJSON(t, resp, &items)
+ // Inbox may be empty, just verify it returns valid JSON array
+ if items == nil {
+ t.Fatal("expected non-nil inbox items array")
+ }
+}
+
+// ---- 404 for non-existent resources ----
+
+func TestNonExistentResources(t *testing.T) {
+ fakeUUID := "00000000-0000-0000-0000-000000000000"
+
+ cases := []struct {
+ name string
+ path string
+ }{
+ {"issue", "/api/issues/" + fakeUUID},
+ {"agent", "/api/agents/" + fakeUUID},
+ {"workspace", "/api/workspaces/" + fakeUUID},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ resp := authRequest(t, "GET", tc.path, nil)
+ resp.Body.Close()
+ if resp.StatusCode != 404 {
+ t.Fatalf("expected 404, got %d", resp.StatusCode)
+ }
+ })
+ }
+}
+
+// ---- Invalid request bodies ----
+
+func TestInvalidRequestBodies(t *testing.T) {
+ resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, nil)
+ defer resp.Body.Close()
+ // Sending nil body should fail with 400
+ if resp.StatusCode != 400 {
+ // Some handlers may return 500 for nil body, that's acceptable too
+ if resp.StatusCode != 500 {
+ t.Fatalf("expected 400 or 500, got %d", resp.StatusCode)
+ }
+ }
+}
+
+// ---- WebSocket integration through full router ----
+
+func TestWebSocketIntegration(t *testing.T) {
+ // Connect WebSocket client
+ wsURL := "ws" + strings.TrimPrefix(testServer.URL, "http") + "/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("WebSocket connection failed: %v", err)
+ }
+ defer conn.Close()
+
+ // Create an issue — this should trigger a WebSocket broadcast
+ resp := authRequest(t, "POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
+ "title": "WebSocket test issue",
+ "status": "todo",
+ })
+ var issue map[string]any
+ readJSON(t, resp, &issue)
+ issueID := issue["id"].(string)
+
+ // Read the WebSocket message
+ conn.SetReadDeadline(time.Now().Add(3 * time.Second))
+ _, msg, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("WebSocket read error: %v", err)
+ }
+
+ // Verify the message contains the issue event
+ var wsMsg map[string]any
+ if err := json.Unmarshal(msg, &wsMsg); err != nil {
+ t.Fatalf("failed to parse WebSocket message: %v", err)
+ }
+ if wsMsg["type"] != "issue:created" {
+ t.Fatalf("expected type 'issue:created', got '%s'", wsMsg["type"])
+ }
+
+ // Update the issue — should trigger another broadcast
+ resp = authRequest(t, "PUT", "/api/issues/"+issueID, map[string]any{
+ "status": "in_progress",
+ })
+ resp.Body.Close()
+
+ conn.SetReadDeadline(time.Now().Add(3 * time.Second))
+ _, msg, err = conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("WebSocket read error on update: %v", err)
+ }
+ var updateMsg map[string]any
+ json.Unmarshal(msg, &updateMsg)
+ if updateMsg["type"] != "issue:updated" {
+ t.Fatalf("expected type 'issue:updated', got '%s'", updateMsg["type"])
+ }
+
+ // Delete the issue — should trigger another broadcast
+ resp = authRequest(t, "DELETE", "/api/issues/"+issueID, nil)
+ resp.Body.Close()
+
+ conn.SetReadDeadline(time.Now().Add(3 * time.Second))
+ _, msg, err = conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("WebSocket read error on delete: %v", err)
+ }
+ var deleteMsg map[string]any
+ json.Unmarshal(msg, &deleteMsg)
+ if deleteMsg["type"] != "issue:deleted" {
+ t.Fatalf("expected type 'issue:deleted', got '%s'", deleteMsg["type"])
+ }
+}
diff --git a/server/internal/middleware/auth_test.go b/server/internal/middleware/auth_test.go
new file mode 100644
index 00000000..0419ce2f
--- /dev/null
+++ b/server/internal/middleware/auth_test.go
@@ -0,0 +1,185 @@
+package middleware
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+func generateToken(claims jwt.MapClaims, secret []byte) string {
+ token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
+ s, _ := token.SignedString(secret)
+ return s
+}
+
+func validClaims() jwt.MapClaims {
+ return jwt.MapClaims{
+ "sub": "test-user-id",
+ "email": "test@multica.ai",
+ "exp": time.Now().Add(time.Hour).Unix(),
+ }
+}
+
+func TestAuth_MissingHeader(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+ if body := w.Body.String(); body != `{"error":"missing authorization header"}`+"\n" {
+ t.Fatalf("unexpected body: %s", body)
+ }
+}
+
+func TestAuth_NoBearerPrefix(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Token some-token")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+ if body := w.Body.String(); body != `{"error":"invalid authorization format"}`+"\n" {
+ t.Fatalf("unexpected body: %s", body)
+ }
+}
+
+func TestAuth_InvalidToken(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer not-a-valid-jwt")
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+}
+
+func TestAuth_ExpiredToken(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ claims := validClaims()
+ claims["exp"] = time.Now().Add(-time.Hour).Unix()
+ token := generateToken(claims, jwtSecret)
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+}
+
+func TestAuth_WrongSecret(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ token := generateToken(validClaims(), []byte("wrong-secret"))
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+}
+
+func TestAuth_WrongSigningMethod(t *testing.T) {
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ t.Fatal("next handler should not be called")
+ }))
+
+ // Use "none" signing method
+ token := jwt.NewWithClaims(jwt.SigningMethodNone, validClaims())
+ s, _ := token.SignedString(jwt.UnsafeAllowNoneSignatureType)
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+s)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", w.Code)
+ }
+}
+
+func TestAuth_ValidToken(t *testing.T) {
+ var gotUserID, gotEmail string
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotUserID = r.Header.Get("X-User-ID")
+ gotEmail = r.Header.Get("X-User-Email")
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ token := generateToken(validClaims(), jwtSecret)
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ if gotUserID != "test-user-id" {
+ t.Fatalf("expected X-User-ID 'test-user-id', got '%s'", gotUserID)
+ }
+ if gotEmail != "test@multica.ai" {
+ t.Fatalf("expected X-User-Email 'test@multica.ai', got '%s'", gotEmail)
+ }
+}
+
+func TestAuth_MissingClaims(t *testing.T) {
+ var gotUserID, gotEmail string
+ handler := Auth(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotUserID = r.Header.Get("X-User-ID")
+ gotEmail = r.Header.Get("X-User-Email")
+ w.WriteHeader(http.StatusOK)
+ }))
+
+ // Token with no sub or email claims, only exp
+ claims := jwt.MapClaims{
+ "exp": time.Now().Add(time.Hour).Unix(),
+ }
+ token := generateToken(claims, jwtSecret)
+
+ req := httptest.NewRequest("GET", "/api/me", nil)
+ req.Header.Set("Authorization", "Bearer "+token)
+ w := httptest.NewRecorder()
+ handler.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+ if gotUserID != "" {
+ t.Fatalf("expected empty X-User-ID, got '%s'", gotUserID)
+ }
+ if gotEmail != "" {
+ t.Fatalf("expected empty X-User-Email, got '%s'", gotEmail)
+ }
+}
diff --git a/server/internal/realtime/hub_test.go b/server/internal/realtime/hub_test.go
new file mode 100644
index 00000000..3a7f8dc5
--- /dev/null
+++ b/server/internal/realtime/hub_test.go
@@ -0,0 +1,177 @@
+package realtime
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/gorilla/websocket"
+)
+
+func newTestHub(t *testing.T) (*Hub, *httptest.Server) {
+ t.Helper()
+ hub := NewHub()
+ go hub.Run()
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) {
+ HandleWebSocket(hub, w, r)
+ })
+ server := httptest.NewServer(mux)
+ return hub, server
+}
+
+func connectWS(t *testing.T, server *httptest.Server) *websocket.Conn {
+ t.Helper()
+ wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/ws"
+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
+ if err != nil {
+ t.Fatalf("failed to connect WebSocket: %v", err)
+ }
+ return conn
+}
+
+func TestHub_ClientRegistration(t *testing.T) {
+ hub, server := newTestHub(t)
+ defer server.Close()
+
+ conn := connectWS(t, server)
+ defer conn.Close()
+
+ time.Sleep(50 * time.Millisecond)
+
+ hub.mu.RLock()
+ count := len(hub.clients)
+ hub.mu.RUnlock()
+
+ if count != 1 {
+ t.Fatalf("expected 1 client, got %d", count)
+ }
+}
+
+func TestHub_Broadcast(t *testing.T) {
+ hub, server := newTestHub(t)
+ defer server.Close()
+
+ conn1 := connectWS(t, server)
+ defer conn1.Close()
+ conn2 := connectWS(t, server)
+ defer conn2.Close()
+
+ time.Sleep(50 * time.Millisecond)
+
+ msg := []byte(`{"type":"issue:created","data":"test"}`)
+ hub.Broadcast(msg)
+
+ conn1.SetReadDeadline(time.Now().Add(2 * time.Second))
+ _, received1, err := conn1.ReadMessage()
+ if err != nil {
+ t.Fatalf("client 1 read error: %v", err)
+ }
+ if string(received1) != string(msg) {
+ t.Fatalf("client 1: expected %s, got %s", msg, received1)
+ }
+
+ conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
+ _, received2, err := conn2.ReadMessage()
+ if err != nil {
+ t.Fatalf("client 2 read error: %v", err)
+ }
+ if string(received2) != string(msg) {
+ t.Fatalf("client 2: expected %s, got %s", msg, received2)
+ }
+}
+
+func TestHub_ClientDisconnect(t *testing.T) {
+ hub, server := newTestHub(t)
+ defer server.Close()
+
+ conn := connectWS(t, server)
+
+ time.Sleep(50 * time.Millisecond)
+
+ hub.mu.RLock()
+ countBefore := len(hub.clients)
+ hub.mu.RUnlock()
+ if countBefore != 1 {
+ t.Fatalf("expected 1 client before disconnect, got %d", countBefore)
+ }
+
+ conn.Close()
+ time.Sleep(100 * time.Millisecond)
+
+ hub.mu.RLock()
+ countAfter := len(hub.clients)
+ hub.mu.RUnlock()
+ if countAfter != 0 {
+ t.Fatalf("expected 0 clients after disconnect, got %d", countAfter)
+ }
+}
+
+func TestHub_BroadcastToMultipleClients(t *testing.T) {
+ hub, server := newTestHub(t)
+ defer server.Close()
+
+ const numClients = 5
+ conns := make([]*websocket.Conn, numClients)
+ for i := 0; i < numClients; i++ {
+ conns[i] = connectWS(t, server)
+ defer conns[i].Close()
+ }
+
+ time.Sleep(50 * time.Millisecond)
+
+ hub.mu.RLock()
+ count := len(hub.clients)
+ hub.mu.RUnlock()
+ if count != numClients {
+ t.Fatalf("expected %d clients, got %d", numClients, count)
+ }
+
+ msg := []byte(`{"type":"test","count":5}`)
+ hub.Broadcast(msg)
+
+ for i, conn := range conns {
+ conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ _, received, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("client %d read error: %v", i, err)
+ }
+ if string(received) != string(msg) {
+ t.Fatalf("client %d: expected %s, got %s", i, msg, received)
+ }
+ }
+}
+
+func TestHub_MultipleBroadcasts(t *testing.T) {
+ hub, server := newTestHub(t)
+ defer server.Close()
+
+ conn := connectWS(t, server)
+ defer conn.Close()
+
+ time.Sleep(50 * time.Millisecond)
+
+ messages := []string{
+ `{"type":"issue:created"}`,
+ `{"type":"issue:updated"}`,
+ `{"type":"issue:deleted"}`,
+ }
+
+ for _, msg := range messages {
+ hub.Broadcast([]byte(msg))
+ }
+
+ for i, expected := range messages {
+ conn.SetReadDeadline(time.Now().Add(2 * time.Second))
+ _, received, err := conn.ReadMessage()
+ if err != nil {
+ t.Fatalf("message %d read error: %v", i, err)
+ }
+ if string(received) != expected {
+ t.Fatalf("message %d: expected %s, got %s", i, expected, received)
+ }
+ }
+}