From 63cec40b488a8865ce193f25c4423ec3e40ae87c Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 01:56:31 +0800 Subject: [PATCH 1/5] chore: add ESLint config, engines field, and v8 coverage tooling --- .eslintrc.json | 4 ++++ package.json | 7 ++++++- vitest.config.ts | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .eslintrc.json diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..7c1a3ad --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": "next/core-web-vitals", + "root": true +} diff --git a/package.json b/package.json index 1aa766b..618d208 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,9 @@ "name": "runbook-orchestrator-dashboard", "version": "1.0.0", "private": true, + "engines": { + "node": ">=20.0.0" + }, "scripts": { "dev": "next dev", "build": "next build", @@ -9,7 +12,8 @@ "lint": "next lint", "type-check": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" }, "dependencies": { "next": "14.2.5", @@ -55,6 +59,7 @@ "eslint-config-next": "14.2.5", "vitest": "^2.0.5", "@vitest/ui": "^2.0.5", + "@vitest/coverage-v8": "^2.0.5", "@testing-library/react": "^16.0.1", "@testing-library/jest-dom": "^6.5.0", "@testing-library/user-event": "^14.5.2", diff --git a/vitest.config.ts b/vitest.config.ts index f0630e2..85ec5f8 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,25 @@ export default defineConfig({ globals: true, include: ["src/**/*.test.{ts,tsx}"], exclude: ["node_modules", ".next"], + coverage: { + provider: "v8", + reporter: ["text", "text-summary", "json-summary", "html"], + include: ["src/lib/**/*.ts", "src/components/**/*.tsx"], + exclude: [ + "src/**/*.test.{ts,tsx}", + "src/test/**", + "src/**/*.d.ts", + "src/components/ui/**", + ], + thresholds: { + "src/lib/**": { + statements: 80, + branches: 75, + functions: 80, + lines: 80, + }, + }, + }, }, resolve: { alias: { From 54285cbb20b1bf6cc2ea977940be273ea199a55f Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 01:56:31 +0800 Subject: [PATCH 2/5] test: cover api client, polling/hotkey/animation hooks, and project metadata --- src/lib/api.test.ts | 61 +++++++++++++++++ src/lib/hooks.more.test.ts | 136 +++++++++++++++++++++++++++++++++++++ src/lib/project.test.ts | 22 ++++++ 3 files changed, 219 insertions(+) create mode 100644 src/lib/api.test.ts create mode 100644 src/lib/hooks.more.test.ts create mode 100644 src/lib/project.test.ts diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts new file mode 100644 index 0000000..30ef6e5 --- /dev/null +++ b/src/lib/api.test.ts @@ -0,0 +1,61 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fetchPublicStats, type PublicStats } from "./api"; + +const sample: PublicStats = { + system: "runbook-orchestrator", + mode: "showcase", + status: "operational", + last_deployed_at: "2026-06-01T00:00:00Z", + metrics: { + commits_30d: 12, + commits_total: 240, + primary_language: "TypeScript", + repo_stars: 3, + lines_of_code: 1390, + }, + schema_version: 1, + generated_at: "2026-06-10T00:00:00Z", +}; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("fetchPublicStats", () => { + it("requests /api/stats and returns the parsed telemetry payload", async () => { + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => sample, + }); + vi.stubGlobal("fetch", fetchMock); + + const result = await fetchPublicStats(); + + expect(result).toEqual(sample); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [path, init] = fetchMock.mock.calls[0]; + expect(path).toBe("/api/stats"); + expect(init?.headers).toMatchObject({ "Content-Type": "application/json" }); + }); + + it("throws a descriptive error when the endpoint returns a non-ok status", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 503, + statusText: "Service Unavailable", + json: async () => ({}), + }), + ); + + await expect(fetchPublicStats()).rejects.toThrow("Public API 503: Service Unavailable"); + }); + + it("propagates network rejections to the caller", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("network down"))); + await expect(fetchPublicStats()).rejects.toThrow("network down"); + }); +}); diff --git a/src/lib/hooks.more.test.ts b/src/lib/hooks.more.test.ts new file mode 100644 index 0000000..aa25fed --- /dev/null +++ b/src/lib/hooks.more.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { useAnimatedNumber, useHotkey, usePolling } from "./hooks"; + +describe("useHotkey", () => { + it("invokes the handler when the key and meta modifier match", () => { + const handler = vi.fn(); + renderHook(() => useHotkey("k", handler, { meta: true })); + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", metaKey: true })); + }); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("ignores keys that do not match", () => { + const handler = vi.fn(); + renderHook(() => useHotkey("k", handler, { meta: true })); + + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "j", metaKey: true })); + }); + expect(handler).not.toHaveBeenCalled(); + }); + + it("detaches the listener on unmount", () => { + const handler = vi.fn(); + const { unmount } = renderHook(() => useHotkey("k", handler)); + unmount(); + act(() => { + window.dispatchEvent(new KeyboardEvent("keydown", { key: "k" })); + }); + expect(handler).not.toHaveBeenCalled(); + }); +}); + +describe("useAnimatedNumber", () => { + let frames: Array<(t: number) => void>; + + beforeEach(() => { + frames = []; + vi.stubGlobal("requestAnimationFrame", (cb: (t: number) => void) => { + frames.push(cb); + return frames.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("eases from 0 toward the target as frames advance", () => { + const { result } = renderHook(() => useAnimatedNumber(100, 50)); + expect(result.current).toBe(0); + + act(() => frames[0]?.(0)); // establish the start timestamp + act(() => frames[1]?.(50)); // full duration elapsed -> progress 1 + + expect(result.current).toBeCloseTo(100, 5); + }); +}); + +describe("usePolling", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("does not fetch while disabled", () => { + const fetcher = vi.fn(); + const { result } = renderHook(() => usePolling(fetcher, 1000, false)); + expect(fetcher).not.toHaveBeenCalled(); + expect(result.current.loading).toBe(true); + }); + + it("loads data on mount and clears the loading flag", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: 7 }); + const { result } = renderHook(() => usePolling(fetcher, 1000, true)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(fetcher).toHaveBeenCalledTimes(1); + expect(result.current.data).toEqual({ value: 7 }); + expect(result.current.loading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it("captures fetch errors without throwing", async () => { + const fetcher = vi.fn().mockRejectedValue(new Error("boom")); + const { result } = renderHook(() => usePolling(fetcher, 1000, true)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + expect(result.current.error).toBeInstanceOf(Error); + expect(result.current.error?.message).toBe("boom"); + expect(result.current.loading).toBe(false); + }); + + it("reschedules another fetch after the interval", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: 1 }); + renderHook(() => usePolling(fetcher, 1000, true)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); + + it("refetches on demand", async () => { + const fetcher = vi.fn().mockResolvedValue({ value: 1 }); + const { result } = renderHook(() => usePolling(fetcher, 100000, true)); + + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetcher).toHaveBeenCalledTimes(1); + + await act(async () => { + result.current.refetch(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(fetcher).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/lib/project.test.ts b/src/lib/project.test.ts new file mode 100644 index 0000000..ddb6cda --- /dev/null +++ b/src/lib/project.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { PROJECT } from "./project"; + +describe("PROJECT metadata", () => { + it("exposes the canonical identity used by the dashboard and telemetry", () => { + expect(PROJECT.slug).toBe("agent-runbook-orchestrator"); + expect(PROJECT.system_slug).toBe("runbook-orchestrator"); + expect(PROJECT.github_url).toContain("github.com/IgnazioDS/agent-runbook-orchestrator"); + }); + + it("declares a non-empty stack and MVP scope", () => { + expect(PROJECT.stack.length).toBeGreaterThan(0); + expect(PROJECT.mvp.length).toBeGreaterThan(0); + expect(PROJECT.stack.every((item) => item.length > 0)).toBe(true); + }); + + it("points every fleet link at the eleventh.dev zone", () => { + expect(PROJECT.eleventh_url).toBe("https://eleventh.dev"); + expect(PROJECT.fleet_url.startsWith("https://eleventh.dev")).toBe(true); + expect(PROJECT.live_url.startsWith("https://")).toBe(true); + }); +}); From e511d94f066dbc577b943a01360162694cf190a8 Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 01:56:31 +0800 Subject: [PATCH 3/5] fix(telemetry): exclude lockfiles and generated dirs from LOC; refresh artifact --- api/_telemetry_static.json | 4 ++-- scripts/compute_telemetry_static.py | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/api/_telemetry_static.json b/api/_telemetry_static.json index 5ca5300..c90c4fb 100644 --- a/api/_telemetry_static.json +++ b/api/_telemetry_static.json @@ -1,4 +1,4 @@ { - "lines_of_code": 1390, - "built_at": "2026-04-27T18:37:45Z" + "lines_of_code": 5778, + "built_at": "2026-06-09T17:55:38Z" } diff --git a/scripts/compute_telemetry_static.py b/scripts/compute_telemetry_static.py index bfaa7f6..4bd18c2 100644 --- a/scripts/compute_telemetry_static.py +++ b/scripts/compute_telemetry_static.py @@ -35,6 +35,20 @@ "dist", "build", ".idea", + ".next", + "coverage", + } +) + +# Generated / lock files are not authored source. Counting them inflates the +# public `lines_of_code` telemetry, which the contract forbids. +EXCLUDE_FILES = frozenset( + { + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "tsconfig.tsbuildinfo", + "_telemetry_static.json", } ) @@ -46,6 +60,8 @@ def count_lines(root: Path) -> int: continue if any(part in EXCLUDE_DIRS for part in path.parts): continue + if path.name in EXCLUDE_FILES: + continue if path.suffix not in SOURCE_EXTS: continue # Exclude the build artifact itself so each run is stable. From cfa0487ee0f449e13245b4b64c20ba1aa9b81152 Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 01:56:31 +0800 Subject: [PATCH 4/5] chore: drop stale NexusRAG env vars; ignore coverage output --- .env.local.example | 16 ++++++++-------- .gitignore | 3 +++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.env.local.example b/.env.local.example index 1b877ec..82f990b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,8 +1,8 @@ -# URL of the NexusRAG API (server-side proxy target) -NEXUSRAG_API_URL=http://localhost:8000 - -# API key passed as Bearer token from the browser (public — no secrets here) -NEXT_PUBLIC_API_KEY=your-api-key-here - -# Corpus ID to use in the /run page -NEXT_PUBLIC_DEFAULT_CORPUS_ID=c1 +# The showcase dashboard talks only to its own same-origin /api/stats endpoint +# (the stdlib Python serverless function in api/stats.py). It needs NO secrets +# or environment variables to build or run — this file is intentionally empty +# of required values. +# +# When this system graduates to a Tier-A workload with a backend BFF, add the +# server-side variables here (e.g. an upstream API base URL). Public values +# meant to reach the browser must be prefixed NEXT_PUBLIC_. diff --git a/.gitignore b/.gitignore index f920d81..951a7b6 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,9 @@ out/ next-env.d.ts.bak tsconfig.tsbuildinfo +# Test coverage reports +coverage/ + # Node node_modules/ package-lock.json From 893e0fb9554df67b7debb76f97d9e7c1f6bca2c3 Mon Sep 17 00:00:00 2001 From: Ignazio De Santis Date: Wed, 10 Jun 2026 01:56:31 +0800 Subject: [PATCH 5/5] ci: add Next.js workflow (lint, type-check, coverage, build) --- .github/workflows/nextjs-ci.yml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/nextjs-ci.yml diff --git a/.github/workflows/nextjs-ci.yml b/.github/workflows/nextjs-ci.yml new file mode 100644 index 0000000..78d99e7 --- /dev/null +++ b/.github/workflows/nextjs-ci.yml @@ -0,0 +1,33 @@ +name: Next.js CI + +on: + push: + branches: [main] + pull_request: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Install dependencies + run: npm install --no-audit --no-fund + + - name: Lint + run: npm run lint + + - name: Type-check + run: npm run type-check + + - name: Test with coverage + run: npm run test:coverage + + - name: Build + run: npm run build