From 6dfc61fa86d8899e112d21814b2c1d62cb27dbd2 Mon Sep 17 00:00:00 2001 From: Jiayuan Zhang Date: Sun, 22 Mar 2026 11:50:25 +0800 Subject: [PATCH] test: add comprehensive test suite (Go unit/integration, Vitest, Playwright E2E) - Add JWT middleware unit tests (8 tests covering all auth edge cases) - Add WebSocket hub tests (5 tests for client lifecycle and broadcast) - Add full HTTP integration tests (12 tests through real Chi router with DB) - Add frontend component tests for login, issues, and issue detail pages - Add auth context unit tests (9 tests for login/logout/name resolution) - Add Playwright E2E tests for auth, issues, comments, and navigation - Configure Vitest with jsdom, React plugin, and path aliases Co-Authored-By: Claude Opus 4.6 --- apps/web/app/(auth)/login/page.test.tsx | 116 ++ .../app/(dashboard)/issues/[id]/page.test.tsx | 241 ++++ apps/web/app/(dashboard)/issues/page.test.tsx | 290 +++++ apps/web/package.json | 11 +- apps/web/test/helpers.tsx | 91 ++ apps/web/test/setup.ts | 33 + apps/web/vitest.config.ts | 19 + e2e/auth.spec.ts | 46 + e2e/comments.spec.ts | 47 + e2e/helpers.ts | 22 + e2e/issues.spec.ts | 77 ++ e2e/navigation.spec.ts | 43 + package.json | 1 + playwright.config.ts | 19 + pnpm-lock.yaml | 1058 ++++++++++++++++- server/cmd/server/integration_test.go | 618 ++++++++++ server/internal/middleware/auth_test.go | 185 +++ server/internal/realtime/hub_test.go | 177 +++ 18 files changed, 3090 insertions(+), 4 deletions(-) create mode 100644 apps/web/app/(auth)/login/page.test.tsx create mode 100644 apps/web/app/(dashboard)/issues/[id]/page.test.tsx create mode 100644 apps/web/app/(dashboard)/issues/page.test.tsx create mode 100644 apps/web/test/helpers.tsx create mode 100644 apps/web/test/setup.ts create mode 100644 apps/web/vitest.config.ts create mode 100644 e2e/auth.spec.ts create mode 100644 e2e/comments.spec.ts create mode 100644 e2e/helpers.ts create mode 100644 e2e/issues.spec.ts create mode 100644 e2e/navigation.spec.ts create mode 100644 playwright.config.ts create mode 100644 server/cmd/server/integration_test.go create mode 100644 server/internal/middleware/auth_test.go create mode 100644 server/internal/realtime/hub_test.go 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) + } + } +}