diff --git a/package.json b/package.json index cae895c..eb4a62f 100644 --- a/package.json +++ b/package.json @@ -1,41 +1,42 @@ { "name": "forge", - "version": "1.0.0", + "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "next lint", - "test": "npm run test:unit && npm run test:integration", - "test:unit": "vitest run tests/unit", - "test:integration": "vitest run tests/integration", - "test:e2e": "playwright test" + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { - "@hookform/resolvers": "^3.9.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^0.460.0", - "next": "14.2.18", - "react": "18.3.1", - "react-dom": "18.3.1", - "react-hook-form": "^7.53.2", - "tailwind-merge": "^2.5.4", - "zod": "^3.23.8" + "lucide-react": "^0.468.0", + "next": "^15.1.6", + "next-themes": "^0.4.4", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "tailwind-merge": "^2.5.5", + "zod": "^3.24.1" }, "devDependencies": { - "@playwright/test": "^1.49.1", "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.0.1", - "@types/node": "^22.9.0", - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", + "@testing-library/react": "^16.1.0", + "@types/node": "^22.10.2", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitest/coverage-v8": "^2.1.8", "autoprefixer": "^10.4.20", + "eslint": "^9.17.0", + "eslint-config-next": "^15.1.6", "jsdom": "^25.0.1", "postcss": "^8.4.49", - "tailwindcss": "^3.4.16", - "typescript": "^5.6.3", - "vitest": "^2.1.5" + "tailwindcss": "^3.4.17", + "tailwindcss-animate": "^1.0.7", + "typescript": "^5.7.2", + "vitest": "^2.1.8" } } diff --git a/src/app/globals-css.test.ts b/src/app/globals-css.test.ts new file mode 100644 index 0000000..b4003c1 --- /dev/null +++ b/src/app/globals-css.test.ts @@ -0,0 +1,34 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +describe("globals.css", () => { + it("defines Tailwind layers and root/dark theme variables", async () => { + const cssPath = path.resolve(process.cwd(), "src/app/globals.css"); + const css = await readFile(cssPath, "utf8"); + + expect(css).toContain("@tailwind base;"); + expect(css).toContain("@tailwind components;"); + expect(css).toContain("@tailwind utilities;"); + + expect(css).toContain(":root {"); + expect(css).toContain(".dark {"); + + expect(css).toContain("--background:"); + expect(css).toContain("--foreground:"); + expect(css).toContain("--primary:"); + expect(css).toContain("--secondary:"); + expect(css).toContain("--accent:"); + expect(css).toContain("--destructive:"); + expect(css).toContain("--chart-1:"); + expect(css).toContain("--chart-5:"); + }); + + it("applies global border and body typography utilities", async () => { + const cssPath = path.resolve(process.cwd(), "src/app/globals.css"); + const css = await readFile(cssPath, "utf8"); + + expect(css).toContain("@apply border-border;"); + expect(css).toContain("@apply bg-background text-foreground font-sans antialiased;"); + }); +}); diff --git a/src/app/globals.css b/src/app/globals.css index e432ad9..e9f30c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,15 +2,66 @@ @tailwind components; @tailwind utilities; -:root { - color-scheme: dark; -} +@layer base { + :root { + --background: 210 40% 98%; + --foreground: 222.2 47.4% 11.2%; + --card: 0 0% 100%; + --card-foreground: 222.2 47.4% 11.2%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 47.4% 11.2%; + --primary: 218.3 79.2% 53.1%; + --primary-foreground: 210 40% 98%; + --secondary: 179.1 84.1% 35.1%; + --secondary-foreground: 210 40% 98%; + --muted: 214.3 31.8% 91.4%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 37.7 92.1% 50.2%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 72.2% 50.6%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 218.3 79.2% 53.1%; + --chart-1: 218.3 79.2% 53.1%; + --chart-2: 179.1 84.1% 35.1%; + --chart-3: 37.7 92.1% 50.2%; + --chart-4: 262.1 83.3% 57.8%; + --chart-5: 0 72.2% 50.6%; + } -html, -body { - min-height: 100%; -} + .dark { + --background: 222.2 47.4% 11.2%; + --foreground: 210 40% 98%; + --card: 222.2 47.4% 14%; + --card-foreground: 210 40% 98%; + --popover: 222.2 47.4% 14%; + --popover-foreground: 210 40% 98%; + --primary: 213.1 93.9% 67.8%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 173.4 80.4% 48%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 43.3 96.4% 56.3%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 91.2% 71.4%; + --destructive-foreground: 222.2 47.4% 11.2%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 213.1 93.9% 67.8%; + --chart-1: 213.1 93.9% 67.8%; + --chart-2: 173.4 80.4% 48%; + --chart-3: 43.3 96.4% 56.3%; + --chart-4: 258.3 89.5% 74.7%; + --chart-5: 0 91.2% 71.4%; + } + + * { + @apply border-border; + } -body { - @apply bg-background text-foreground antialiased; + body { + @apply bg-background text-foreground font-sans antialiased; + } } diff --git a/src/app/layout.test.tsx b/src/app/layout.test.tsx new file mode 100644 index 0000000..ae886f0 --- /dev/null +++ b/src/app/layout.test.tsx @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("next/font/google", () => ({ + Inter: () => ({ variable: "--font-inter-mock" }) +})); + +vi.mock("@/lib/config/env", () => ({ + env: { + NEXT_PUBLIC_APP_NAME: "Forge Test App" + } +})); + +import RootLayout, { metadata } from "@/app/layout"; + +describe("app layout", () => { + it("exports metadata with app name title", () => { + expect(metadata.title).toBe("Forge Test App"); + expect(metadata.description).toBe("Forge client portal"); + }); + + it("renders html/body shell with expected classes", () => { + const tree = RootLayout({ children:
}); + + expect(tree.type).toBe("html"); + expect(tree.props.lang).toBe("en"); + expect(tree.props.className).toBe("--font-inter-mock"); + + const body = tree.props.children; + expect(body.type).toBe("body"); + expect(body.props.className).toContain("font-sans"); + expect(body.props.className).toContain("antialiased"); + expect(body.props.children.props.id).toBe("child"); + }); +}); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e8e96a0..e407c59 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,9 +1,9 @@ import type { Metadata } from "next"; -import { Manrope } from "next/font/google"; +import { Inter } from "next/font/google"; import "@/app/globals.css"; import { env } from "@/lib/config/env"; -const manrope = Manrope({ subsets: ["latin"], variable: "--font-manrope" }); +const inter = Inter({ subsets: ["latin"], variable: "--font-inter" }); export const metadata: Metadata = { title: env.NEXT_PUBLIC_APP_NAME, @@ -12,8 +12,8 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }): JSX.Element { return ( - - {children} + + {children} ); } diff --git a/src/config/__tests__/package-json.test.ts b/src/config/__tests__/package-json.test.ts new file mode 100644 index 0000000..a9686cd --- /dev/null +++ b/src/config/__tests__/package-json.test.ts @@ -0,0 +1,73 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +type PackageJson = { + name: string; + version: string; + private: boolean; + scripts: Record; + dependencies: Record; + devDependencies: Record; +}; + +const packageJsonPath = path.resolve(process.cwd(), "package.json"); +const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PackageJson; + +describe("package.json", () => { + it("defines expected package identity", () => { + expect(packageJson.name).toBe("forge"); + expect(packageJson.version).toBe("0.1.0"); + expect(packageJson.private).toBe(true); + }); + + it("exposes expected app scripts", () => { + expect(packageJson.scripts).toMatchObject({ + dev: "next dev", + build: "next build", + start: "next start", + lint: "next lint", + test: "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }); + }); + + it("includes runtime dependencies needed by app and UI", () => { + const deps = packageJson.dependencies; + + expect(deps).toEqual( + expect.objectContaining({ + next: expect.stringMatching(/^\^/), + react: expect.stringMatching(/^\^/), + "react-dom": expect.stringMatching(/^\^/), + zod: expect.stringMatching(/^\^/), + "next-themes": expect.stringMatching(/^\^/), + "lucide-react": expect.stringMatching(/^\^/), + "class-variance-authority": expect.stringMatching(/^\^/), + clsx: expect.stringMatching(/^\^/), + "tailwind-merge": expect.stringMatching(/^\^/) + }) + ); + }); + + it("includes required testing/tooling dev dependencies", () => { + const devDeps = packageJson.devDependencies; + + expect(devDeps).toEqual( + expect.objectContaining({ + vitest: expect.stringMatching(/^\^/), + jsdom: expect.stringMatching(/^\^/), + "@testing-library/react": expect.stringMatching(/^\^/), + "@testing-library/jest-dom": expect.stringMatching(/^\^/), + "@vitest/coverage-v8": expect.stringMatching(/^\^/), + typescript: expect.stringMatching(/^\^/), + eslint: expect.stringMatching(/^\^/), + "eslint-config-next": expect.stringMatching(/^\^/), + tailwindcss: expect.stringMatching(/^\^/), + postcss: expect.stringMatching(/^\^/), + autoprefixer: expect.stringMatching(/^\^/) + }) + ); + }); +}); diff --git a/src/config/__tests__/tailwind-config.test.ts b/src/config/__tests__/tailwind-config.test.ts new file mode 100644 index 0000000..2e50fd2 --- /dev/null +++ b/src/config/__tests__/tailwind-config.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import config from "../../../../tailwind.config"; + +describe("tailwind.config", () => { + it("uses class-based dark mode", () => { + expect(config.darkMode).toEqual(["class"]); + }); + + it("scans app/component/hook/lib source globs", () => { + expect(config.content).toEqual([ + "./src/app/**/*.{ts,tsx}", + "./src/components/**/*.{ts,tsx}", + "./src/hooks/**/*.{ts,tsx}", + "./src/lib/**/*.{ts,tsx}" + ]); + }); + + it("defines key extended color tokens", () => { + const colors = config.theme?.extend?.colors as Record; + + expect(colors.border).toBe("hsl(var(--border))"); + expect(colors.input).toBe("hsl(var(--border))"); + expect(colors.ring).toBe("hsl(var(--primary))"); + expect(colors.background).toBe("hsl(var(--background))"); + expect(colors.foreground).toBe("hsl(var(--foreground))"); + expect(colors.primary).toEqual({ + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))" + }); + expect(colors.secondary).toEqual({ + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))" + }); + expect(colors.destructive).toEqual({ + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))" + }); + expect(colors["chart-1"]).toBe("hsl(var(--chart-1))"); + expect(colors["chart-5"]).toBe("hsl(var(--chart-5))"); + }); + + it("defines custom radii and font family", () => { + const extend = config.theme?.extend as { + borderRadius: Record; + fontFamily: Record; + }; + + expect(extend.borderRadius).toEqual({ + lg: "0.5rem", + md: "0.375rem", + xl: "0.75rem" + }); + + expect(extend.fontFamily.sans).toEqual([ + "var(--font-inter)", + "Inter", + "system-ui", + "sans-serif" + ]); + }); + + it("registers tailwindcss-animate plugin", () => { + expect(Array.isArray(config.plugins)).toBe(true); + expect(config.plugins).toHaveLength(1); + expect(typeof config.plugins?.[0]).toBe("function"); + }); +}); diff --git a/src/config/__tests__/vitest-config.test.ts b/src/config/__tests__/vitest-config.test.ts new file mode 100644 index 0000000..bae2a88 --- /dev/null +++ b/src/config/__tests__/vitest-config.test.ts @@ -0,0 +1,51 @@ +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import config from "../../../../vitest.config"; + +describe("vitest.config", () => { + it("uses src alias mapped to an absolute src path", () => { + const aliasPath = config.resolve?.alias?.["@"]; + + expect(typeof aliasPath).toBe("string"); + expect(path.isAbsolute(aliasPath as string)).toBe(true); + expect((aliasPath as string).endsWith(`${path.sep}src`)).toBe(true); + }); + + it("configures jsdom test environment and setup file", () => { + expect(config.test?.environment).toBe("jsdom"); + expect(config.test?.globals).toBe(true); + expect(config.test?.setupFiles).toEqual(["./src/test/setup-tests.ts"]); + }); + + it("includes and excludes expected test patterns", () => { + expect(config.test?.include).toEqual(["src/**/*.test.ts", "src/**/*.test.tsx"]); + expect(config.test?.exclude).toEqual([ + "node_modules", + ".next", + "dist", + "coverage", + "e2e" + ]); + }); + + it("enables consistent mock lifecycle options", () => { + expect(config.test?.clearMocks).toBe(true); + expect(config.test?.restoreMocks).toBe(true); + expect(config.test?.mockReset).toBe(true); + }); + + it("defines coverage provider/reporters and source include rules", () => { + const coverage = config.test?.coverage; + + expect(coverage?.provider).toBe("v8"); + expect(coverage?.reporter).toEqual(["text", "html", "lcov"]); + expect(coverage?.reportsDirectory).toBe("./coverage"); + expect(coverage?.include).toEqual(["src/**/*.{ts,tsx}"]); + expect(coverage?.exclude).toEqual([ + "src/**/*.d.ts", + "src/**/__tests__/**", + "src/test/**", + "src/**/index.ts" + ]); + }); +}); diff --git a/src/hooks/__tests__/use-sse.test.ts b/src/hooks/__tests__/use-sse.test.ts new file mode 100644 index 0000000..1c7f719 --- /dev/null +++ b/src/hooks/__tests__/use-sse.test.ts @@ -0,0 +1,270 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useSse } from "@/hooks/use-sse"; +import type { PipelineEvent } from "@/lib/types/domain"; + +class MockEventSource { + static instances: MockEventSource[] = []; + static throwOnNext = false; + + public readonly url: string; + public onmessage: ((event: MessageEvent) => void) | null = null; + public onerror: ((event: Event) => void) | null = null; + public closed = false; + + constructor(url: string) { + if (MockEventSource.throwOnNext) { + MockEventSource.throwOnNext = false; + throw new Error("EventSource init failed"); + } + + this.url = url; + MockEventSource.instances.push(this); + } + + close(): void { + this.closed = true; + } + + emitMessage(data: unknown, lastEventId = ""): void { + const event = { data: JSON.stringify(data), lastEventId } as MessageEvent; + this.onmessage?.(event); + } + + emitRawMessage(rawData: string, lastEventId = ""): void { + const event = { data: rawData, lastEventId } as MessageEvent; + this.onmessage?.(event); + } + + emitError(): void { + this.onerror?.(new Event("error")); + } +} + +describe("useSse", () => { + beforeEach((): void => { + vi.useFakeTimers(); + MockEventSource.instances = []; + MockEventSource.throwOnNext = false; + globalThis.EventSource = MockEventSource as unknown as typeof EventSource; + }); + + afterEach((): void => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + it("opens an initial SSE connection for the project", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]?.url).toBe("/api/projects/project-1/events"); + }); + + it("parses incoming messages and forwards events", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + const source = MockEventSource.instances[0]; + const event = { + type: "stats", + payload: { + tokensUsed: 10, + durationMs: 100 + } + } as unknown as PipelineEvent; + + act((): void => { + source?.emitMessage(event, "evt-1"); + }); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(event); + }); + + it("ignores malformed JSON payloads without crashing", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + const source = MockEventSource.instances[0]; + + act((): void => { + source?.emitRawMessage("not-json"); + }); + + expect(onEvent).not.toHaveBeenCalled(); + }); + + it("reconnects with message lastEventId after stream error", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + const firstSource = MockEventSource.instances[0]; + + act((): void => { + firstSource?.emitMessage( + { type: "log", payload: { message: "ok" } } as unknown as PipelineEvent, + "evt-42" + ); + firstSource?.emitError(); + }); + + act((): void => { + vi.advanceTimersByTime(1000); + }); + + expect(MockEventSource.instances).toHaveLength(2); + expect(MockEventSource.instances[1]?.url).toBe( + "/api/projects/project-1/events?lastEventId=evt-42" + ); + }); + + it("falls back to payload id when MessageEvent.lastEventId is empty", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + const firstSource = MockEventSource.instances[0]; + const payloadWithId = { + id: "payload-evt-9", + type: "log", + payload: { message: "ok" } + } as unknown as PipelineEvent; + + act((): void => { + firstSource?.emitMessage(payloadWithId, ""); + firstSource?.emitError(); + }); + + act((): void => { + vi.advanceTimersByTime(1000); + }); + + expect(MockEventSource.instances).toHaveLength(2); + expect(MockEventSource.instances[1]?.url).toBe( + "/api/projects/project-1/events?lastEventId=payload-evt-9" + ); + }); + + it("uses exponential backoff and caps retry delay at 5000ms", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + renderHook(() => useSse("project-1", onEvent)); + + const first = MockEventSource.instances[0]; + + act(() => { + first?.emitError(); + vi.advanceTimersByTime(1000); // reconnect #1 (next delay 2000) + }); + + const second = MockEventSource.instances[1]; + act(() => { + second?.emitError(); + vi.advanceTimersByTime(2000); // reconnect #2 (next delay 4000) + }); + + const third = MockEventSource.instances[2]; + act(() => { + third?.emitError(); + vi.advanceTimersByTime(4000); // reconnect #3 (next delay 5000 cap) + }); + + const fourth = MockEventSource.instances[3]; + act(() => { + fourth?.emitError(); + vi.advanceTimersByTime(5000); // reconnect #4 (still capped) + }); + + expect(MockEventSource.instances).toHaveLength(5); + }); + + it("retries when EventSource constructor throws", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + MockEventSource.throwOnNext = true; + renderHook(() => useSse("project-1", onEvent)); + + // First connect fails before an instance is tracked. + expect(MockEventSource.instances).toHaveLength(0); + + act(() => { + vi.advanceTimersByTime(1000); + }); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]?.url).toBe("/api/projects/project-1/events"); + }); + + it("uses the latest callback without reinitializing connection", () => { + const firstHandler = vi.fn<(event: PipelineEvent) => void>(); + const secondHandler = vi.fn<(event: PipelineEvent) => void>(); + + const { rerender } = renderHook( + ({ onEvent }) => useSse("project-1", onEvent), + { initialProps: { onEvent: firstHandler } } + ); + + expect(MockEventSource.instances).toHaveLength(1); + + rerender({ onEvent: secondHandler }); + + const source = MockEventSource.instances[0]; + const event = { type: "stats", payload: { tokensUsed: 3 } } as unknown as PipelineEvent; + + act(() => { + source?.emitMessage(event, "evt-100"); + }); + + expect(firstHandler).not.toHaveBeenCalled(); + expect(secondHandler).toHaveBeenCalledTimes(1); + expect(MockEventSource.instances).toHaveLength(1); + }); + + it("closes current stream and resets state when projectId changes", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + const { rerender } = renderHook( + ({ projectId }) => useSse(projectId, onEvent), + { initialProps: { projectId: "project-1" } } + ); + + const first = MockEventSource.instances[0]; + act(() => { + first?.emitMessage({ id: "evt-1", type: "log", payload: {} } as PipelineEvent, "evt-1"); + }); + + rerender({ projectId: "project-2" }); + + expect(first?.closed).toBe(true); + expect(MockEventSource.instances).toHaveLength(2); + expect(MockEventSource.instances[1]?.url).toBe("/api/projects/project-2/events"); + }); + + it("closes stream on unmount and prevents future reconnects", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + const { unmount } = renderHook(() => useSse("project-1", onEvent)); + + const source = MockEventSource.instances[0]; + + act(() => { + source?.emitError(); + }); + + unmount(); + + expect(source?.closed).toBe(true); + + act(() => { + vi.advanceTimersByTime(10_000); + }); + + expect(MockEventSource.instances).toHaveLength(1); + }); +}); diff --git a/src/hooks/use-sse.test.ts b/src/hooks/use-sse.test.ts new file mode 100644 index 0000000..63a156f --- /dev/null +++ b/src/hooks/use-sse.test.ts @@ -0,0 +1,291 @@ +import { render } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { PipelineEvent } from "@/lib/types/domain"; +import { useSse, type SseHookError } from "@/hooks/use-sse"; + +class MockEventSource { + static instances: MockEventSource[] = []; + + readonly url: string; + readonly withCredentials = false; + readonly CONNECTING = 0; + readonly OPEN = 1; + readonly CLOSED = 2; + readyState = 1; + closeCalls = 0; + + onopen: ((this: EventSource, ev: Event) => unknown) | null = null; + onmessage: ((this: EventSource, ev: MessageEvent) => unknown) | null = null; + onerror: ((this: EventSource, ev: Event) => unknown) | null = null; + + constructor(url: string | URL) { + this.url = String(url); + MockEventSource.instances.push(this); + } + + close(): void { + this.closeCalls += 1; + this.readyState = this.CLOSED; + } + + addEventListener(): void { + // Not needed for these tests. + } + + removeEventListener(): void { + // Not needed for these tests. + } + + dispatchEvent(): boolean { + return true; + } + + emitRaw(data: string, lastEventId = ""): void { + this.onmessage?.call(this as unknown as EventSource, { + data, + lastEventId + } as MessageEvent); + } + + emitJson(payload: unknown, lastEventId = ""): void { + this.emitRaw(JSON.stringify(payload), lastEventId); + } + + emitError(): void { + this.onerror?.call(this as unknown as EventSource, new Event("error")); + } +} + +interface HarnessProps { + projectId: string; + onEvent: (event: PipelineEvent) => void; + onError?: (error: SseHookError) => void; +} + +function Harness({ projectId, onEvent, onError }: HarnessProps): null { + useSse(projectId, onEvent, onError); + return null; +} + +describe("useSse", () => { + beforeEach(() => { + vi.useFakeTimers(); + MockEventSource.instances = []; + vi.stubGlobal("EventSource", MockEventSource); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("opens an EventSource for the project events endpoint", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + render(); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0].url).toBe("/api/projects/project-1/events"); + }); + + it("forwards parsed events to onEvent", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + render(); + + const source = MockEventSource.instances[0]; + const payload = { + id: "evt-100", + type: "stats", + payload: { durationMs: 42 } + } as unknown as PipelineEvent; + + source.emitJson(payload); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(payload); + }); + + it("uses MessageEvent.lastEventId for resume after reconnect", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + render(); + + const source = MockEventSource.instances[0]; + source.emitJson({ type: "progress", payload: { pct: 50 } }, "server-id-9"); + + source.emitError(); + vi.advanceTimersByTime(1000); + + const reconnect = MockEventSource.instances[1]; + expect(reconnect.url).toContain("lastEventId=server-id-9"); + }); + + it("tracks fallback payload id when MessageEvent.lastEventId is absent", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + render(); + + const initialSource = MockEventSource.instances[0]; + initialSource.emitJson({ + id: "evt-101", + type: "stats", + payload: { durationMs: 42 } + }); + + initialSource.emitError(); + vi.advanceTimersByTime(1000); + + const reconnectedSource = MockEventSource.instances[1]; + expect(reconnectedSource.url).toContain("lastEventId=evt-101"); + }); + + it("extracts fallback id from root eventId and nested payload ids", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + render(); + + const first = MockEventSource.instances[0]; + first.emitJson({ eventId: 123, type: "progress", payload: {} }); + first.emitError(); + vi.advanceTimersByTime(1000); + + expect(MockEventSource.instances[1].url).toContain("lastEventId=123"); + + const second = MockEventSource.instances[1]; + second.emitJson({ type: "progress", payload: { id: "nested-7" } }); + second.emitError(); + vi.advanceTimersByTime(2000); + + expect(MockEventSource.instances[2].url).toContain("lastEventId=nested-7"); + + const third = MockEventSource.instances[2]; + third.emitJson({ type: "progress", payload: { eventId: 456 } }); + third.emitError(); + vi.advanceTimersByTime(4000); + + expect(MockEventSource.instances[3].url).toContain("lastEventId=456"); + }); + + it("reports parse errors with structured metadata and raw payload", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + const onError = vi.fn<(error: SseHookError) => void>(); + + render(); + + MockEventSource.instances[0].emitRaw("{not-json", "evt-last-1"); + + expect(onEvent).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "parse", + status: 0, + projectId: "project-1", + lastEventId: "evt-last-1", + rawData: "{not-json" + }) + ); + }); + + it("reports connection errors and reconnects with exponential backoff capped at 5000ms", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + const onError = vi.fn<(error: SseHookError) => void>(); + + render(); + + const first = MockEventSource.instances[0]; + first.emitError(); + + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + kind: "connection", + projectId: "project-1" + }) + ); + + vi.advanceTimersByTime(999); + expect(MockEventSource.instances).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(MockEventSource.instances).toHaveLength(2); + + MockEventSource.instances[1].emitError(); + vi.advanceTimersByTime(1999); + expect(MockEventSource.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockEventSource.instances).toHaveLength(3); + + MockEventSource.instances[2].emitError(); + vi.advanceTimersByTime(3999); + expect(MockEventSource.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockEventSource.instances).toHaveLength(4); + + MockEventSource.instances[3].emitError(); + vi.advanceTimersByTime(4999); + expect(MockEventSource.instances).toHaveLength(4); + vi.advanceTimersByTime(1); + expect(MockEventSource.instances).toHaveLength(5); + + MockEventSource.instances[4].emitError(); + vi.advanceTimersByTime(4999); + expect(MockEventSource.instances).toHaveLength(5); + vi.advanceTimersByTime(1); + expect(MockEventSource.instances).toHaveLength(6); + }); + + it("resets retry delay and lastEventId when project changes", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + const view = render(); + + const first = MockEventSource.instances[0]; + first.emitJson({ id: "evt-keep", type: "progress", payload: {} }); + first.emitError(); + vi.advanceTimersByTime(1000); + + expect(MockEventSource.instances[1].url).toContain("lastEventId=evt-keep"); + + view.rerender(); + + const project2Initial = MockEventSource.instances[2]; + expect(project2Initial.url).toBe("/api/projects/project-2/events"); + + project2Initial.emitError(); + vi.advanceTimersByTime(999); + expect(MockEventSource.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + + const project2Reconnect = MockEventSource.instances[3]; + expect(project2Reconnect.url).toBe("/api/projects/project-2/events"); + }); + + it("closes source and cancels pending reconnect on unmount", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + + const view = render(); + + const source = MockEventSource.instances[0]; + source.emitError(); + expect(source.closeCalls).toBeGreaterThan(0); + + view.unmount(); + vi.runOnlyPendingTimers(); + + expect(MockEventSource.instances).toHaveLength(1); + }); + + it("falls back to console.error when onError is not provided", () => { + const onEvent = vi.fn<(event: PipelineEvent) => void>(); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + + render(); + + MockEventSource.instances[0].emitRaw("not-json"); + + expect(errorSpy).toHaveBeenCalled(); + const firstCall = errorSpy.mock.calls[0] ?? []; + expect(String(firstCall[0])).toContain("[useSse]"); + }); +}); diff --git a/src/hooks/use-sse.ts b/src/hooks/use-sse.ts index bd1c508..a826e8a 100644 --- a/src/hooks/use-sse.ts +++ b/src/hooks/use-sse.ts @@ -3,36 +3,176 @@ import { useEffect, useRef } from "react"; import type { PipelineEvent } from "@/lib/types/domain"; -export function useSse(projectId: string, onEvent: (event: PipelineEvent) => void): void { - const retryRef = useRef(1000); +const INITIAL_RETRY_MS = 1000; +const MAX_RETRY_MS = 5000; + +export interface SseHookError { + kind: "parse" | "connection"; + status: number; + message: string; + projectId: string; + lastEventId?: string; + rawData?: string; + cause?: unknown; +} + +interface EventWithOptionalId { + id?: string | number; + eventId?: string | number; + payload?: unknown; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function toStringId(value: unknown): string | undefined { + if (typeof value === "string" && value.length > 0) { + return value; + } + + if (typeof value === "number" && Number.isFinite(value)) { + return String(value); + } + + return undefined; +} + +function extractEventId(event: unknown): string | undefined { + if (!isRecord(event)) { + return undefined; + } + + const root = event as EventWithOptionalId; + const rootId = toStringId(root.id) ?? toStringId(root.eventId); + if (rootId) { + return rootId; + } + + if (!isRecord(root.payload)) { + return undefined; + } + + return toStringId(root.payload.id) ?? toStringId(root.payload.eventId); +} + +function buildEventsUrl(projectId: string, lastEventId?: string): string { + const url = new URL(`/api/projects/${projectId}/events`, window.location.origin); + + if (lastEventId) { + // Replay invariant: carry forward the most recently processed event id. + url.searchParams.set("lastEventId", lastEventId); + } + + return `${url.pathname}${url.search}`; +} + +export function useSse( + projectId: string, + onEvent: (event: PipelineEvent) => void, + onError?: (error: SseHookError) => void +): void { + const retryRef = useRef(INITIAL_RETRY_MS); + const lastEventIdRef = useRef(undefined); + + useEffect((): void => { + retryRef.current = INITIAL_RETRY_MS; + lastEventIdRef.current = undefined; + }, [projectId]); useEffect(() => { let source: EventSource | null = null; - let timeoutId: ReturnType | null = null; + let timeoutId: ReturnType | undefined; + let disposed = false; + + const reportError = (error: SseHookError): void => { + if (onError) { + onError(error); + return; + } + + console.error(`[useSse] ${error.message}`, { + projectId: error.projectId, + status: error.status, + kind: error.kind, + lastEventId: error.lastEventId, + cause: error.cause + }); + }; + + const clearConnection = (): void => { + if (source) { + source.close(); + source = null; + } + + if (timeoutId) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + }; const connect = (): void => { - source = new EventSource(`/api/projects/${projectId}/events`); + if (disposed) { + return; + } + + source = new EventSource(buildEventsUrl(projectId, lastEventIdRef.current)); + + source.onmessage = (message: MessageEvent): void => { + try { + const parsed = JSON.parse(message.data) as PipelineEvent; + const fallbackId = extractEventId(parsed); + const resolvedId = message.lastEventId || fallbackId; + + if (resolvedId) { + lastEventIdRef.current = resolvedId; + } - source.onmessage = (message): void => { - const parsed = JSON.parse(message.data) as PipelineEvent; - onEvent(parsed); - retryRef.current = 1000; + onEvent(parsed); + retryRef.current = INITIAL_RETRY_MS; + } catch (cause: unknown) { + reportError({ + kind: "parse", + status: 400, + message: "Failed to parse SSE payload as JSON.", + projectId, + lastEventId: lastEventIdRef.current, + rawData: message.data, + cause + }); + } }; source.onerror = (): void => { - source?.close(); - timeoutId = setTimeout(() => { - retryRef.current = Math.min(5000, retryRef.current * 2); + clearConnection(); + + if (disposed) { + return; + } + + const retryInMs = retryRef.current; + + reportError({ + kind: "connection", + status: 503, + message: `SSE connection dropped. Retrying in ${retryInMs}ms.`, + projectId, + lastEventId: lastEventIdRef.current + }); + + timeoutId = setTimeout((): void => { + retryRef.current = Math.min(MAX_RETRY_MS, retryRef.current * 2); connect(); - }, retryRef.current); + }, retryInMs); }; }; connect(); return (): void => { - source?.close(); - if (timeoutId) clearTimeout(timeoutId); + disposed = true; + clearConnection(); }; - }, [onEvent, projectId]); + }, [onEvent, onError, projectId]); } diff --git a/src/test/setup-tests.integration.test.ts b/src/test/setup-tests.integration.test.ts new file mode 100644 index 0000000..3aadbda --- /dev/null +++ b/src/test/setup-tests.integration.test.ts @@ -0,0 +1,34 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +const persistentMock = vi.fn(); +const sampleObj = { + read(): string { + return "original"; + } +}; + +describe("test setup integration", () => { + it("provides jest-dom matchers and records mock calls", () => { + render(
hello
); + + expect(screen.getByTestId("node")).toBeInTheDocument(); + + persistentMock("a"); + persistentMock("b"); + expect(persistentMock).toHaveBeenCalledTimes(2); + + vi.spyOn(sampleObj, "read").mockReturnValue("mocked"); + expect(sampleObj.read()).toBe("mocked"); + }); + + it("auto-cleans DOM and restores/clears mocks between tests", () => { + expect(screen.queryByTestId("node")).not.toBeInTheDocument(); + + // Cleared by setup-tests afterEach vi.clearAllMocks(). + expect(persistentMock).toHaveBeenCalledTimes(0); + + // Restored by setup-tests afterEach vi.restoreAllMocks(). + expect(sampleObj.read()).toBe("original"); + }); +}); diff --git a/src/test/setup-tests.ts b/src/test/setup-tests.ts new file mode 100644 index 0000000..790194d --- /dev/null +++ b/src/test/setup-tests.ts @@ -0,0 +1,9 @@ +import "@testing-library/jest-dom/vitest"; +import { cleanup } from "@testing-library/react"; +import { afterEach, vi } from "vitest"; + +afterEach((): void => { + cleanup(); + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); diff --git a/src/test/setup.test.ts b/src/test/setup.test.ts new file mode 100644 index 0000000..b317472 --- /dev/null +++ b/src/test/setup.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +describe("test setup matchMedia polyfill", () => { + const original = Object.getOwnPropertyDescriptor(window, "matchMedia"); + + afterEach(() => { + vi.resetModules(); + + if (original) { + Object.defineProperty(window, "matchMedia", original); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (window as Window & { matchMedia?: unknown }).matchMedia; + }); + + it("defines window.matchMedia when missing", async () => { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete (window as Window & { matchMedia?: unknown }).matchMedia; + + await import("@/test/setup"); + + expect(window.matchMedia).toBeTypeOf("function"); + + const result = window.matchMedia("(min-width: 768px)"); + expect(result.matches).toBe(false); + expect(result.media).toBe("(min-width: 768px)"); + expect(result.addEventListener).toBeTypeOf("function"); + expect(result.removeEventListener).toBeTypeOf("function"); + expect(result.dispatchEvent(new Event("change"))).toBe(false); + }); + + it("does not override an existing window.matchMedia", async () => { + const existing = vi.fn((query: string) => ({ + matches: true, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(() => true) + })) as unknown as typeof window.matchMedia; + + Object.defineProperty(window, "matchMedia", { + writable: true, + value: existing + }); + + await import("@/test/setup"); + + expect(window.matchMedia).toBe(existing); + }); +}); diff --git a/src/test/setup.ts b/src/test/setup.ts new file mode 100644 index 0000000..cee1533 --- /dev/null +++ b/src/test/setup.ts @@ -0,0 +1,25 @@ +import "@testing-library/jest-dom/vitest"; + +if (typeof window !== "undefined" && !window.matchMedia) { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: (query: string): MediaQueryList => ({ + matches: false, + media: query, + onchange: null, + addListener: (): void => { + // Deprecated API intentionally no-op for compatibility. + }, + removeListener: (): void => { + // Deprecated API intentionally no-op for compatibility. + }, + addEventListener: (): void => { + // No-op for test environment. + }, + removeEventListener: (): void => { + // No-op for test environment. + }, + dispatchEvent: (): boolean => false + }) + }); +} diff --git a/src/test/tailwind-config.test.ts b/src/test/tailwind-config.test.ts new file mode 100644 index 0000000..dfd3382 --- /dev/null +++ b/src/test/tailwind-config.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import config from "../../tailwind.config"; + +describe("tailwind.config", () => { + it("uses class-based dark mode and expected content globs", () => { + expect(config.darkMode).toEqual(["class"]); + expect(config.content).toEqual( + expect.arrayContaining([ + "./src/app/**/*.{ts,tsx}", + "./src/components/**/*.{ts,tsx}", + "./src/hooks/**/*.{ts,tsx}", + "./src/lib/**/*.{ts,tsx}" + ]) + ); + }); + + it("defines theme tokens used by app styles", () => { + const extend = config.theme?.extend as Record; + + expect(extend.colors.primary.DEFAULT).toBe("hsl(var(--primary))"); + expect(extend.colors.primary.foreground).toBe("hsl(var(--primary-foreground))"); + expect(extend.colors.chart["1"]).toBe("hsl(var(--chart-1))"); + expect(extend.borderRadius.lg).toBe("0.5rem"); + expect(extend.fontFamily.sans).toEqual( + expect.arrayContaining(["var(--font-inter)", "Inter", "ui-sans-serif"]) + ); + }); + + it("registers animation plugin", () => { + expect(config.plugins).toBeDefined(); + expect(Array.isArray(config.plugins)).toBe(true); + expect((config.plugins as unknown[]).length).toBeGreaterThan(0); + }); +}); diff --git a/src/test/vitest-config.test.ts b/src/test/vitest-config.test.ts new file mode 100644 index 0000000..3d5b373 --- /dev/null +++ b/src/test/vitest-config.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import config from "../../vitest.config"; + +describe("vitest.config", () => { + it("defines src alias and jsdom test environment", () => { + expect(config.resolve?.alias).toBeDefined(); + const alias = config.resolve?.alias as Record; + expect(alias["@"]).toMatch(/\/src$/); + + expect(config.test?.environment).toBe("jsdom"); + expect(config.test?.globals).toBe(true); + }); + + it("includes expected test patterns and setup file", () => { + expect(config.test?.include).toContain("src/**/*.{test,spec}.{ts,tsx}"); + expect(config.test?.setupFiles).toContain("./src/test/setup.ts"); + }); + + it("enables v8 coverage with expected reporters and paths", () => { + expect(config.test?.coverage?.provider).toBe("v8"); + expect(config.test?.coverage?.reporter).toEqual(["text", "html"]); + expect(config.test?.coverage?.include).toContain("src/**/*.{ts,tsx}"); + expect(config.test?.coverage?.exclude).toContain("src/**/*.d.ts"); + expect(config.test?.coverage?.exclude).toContain("src/test/**"); + }); +}); diff --git a/tailwind.config.ts b/tailwind.config.ts index a8c5373..5b0c4fe 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,30 +1,69 @@ import type { Config } from "tailwindcss"; +import tailwindcssAnimate from "tailwindcss-animate"; const config: Config = { darkMode: ["class"], - content: ["./src/**/*.{ts,tsx}"], + content: [ + "./src/app/**/*.{ts,tsx}", + "./src/components/**/*.{ts,tsx}", + "./src/hooks/**/*.{ts,tsx}", + "./src/lib/**/*.{ts,tsx}" + ], theme: { extend: { colors: { - primary: "#3B82F6", - secondary: "#14B8A6", - background: "#0B1020", - foreground: "#E5E7EB", - muted: "#1F2937", - accent: "#8B5CF6", - destructive: "#EF4444" + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))" + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))" + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))" + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))" + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))" + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))" + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))" + }, + chart: { + "1": "hsl(var(--chart-1))", + "2": "hsl(var(--chart-2))", + "3": "hsl(var(--chart-3))", + "4": "hsl(var(--chart-4))", + "5": "hsl(var(--chart-5))" + } }, borderRadius: { - lg: "10px", - xl: "12px", - "2xl": "16px" + lg: "0.5rem", + md: "0.375rem", + xl: "0.75rem" }, fontFamily: { - sans: ["Manrope", "ui-sans-serif", "system-ui", "sans-serif"] + sans: ["var(--font-inter)", "Inter", "ui-sans-serif", "system-ui", "sans-serif"] } } }, - plugins: [] + plugins: [tailwindcssAnimate] }; export default config; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b029f7d --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + "@": path.resolve(__dirname, "./src") + } + }, + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.{test,spec}.{ts,tsx}"], + coverage: { + provider: "v8", + reporter: ["text", "html"], + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.d.ts", "src/test/**"] + } + } +});