Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -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_.
4 changes: 4 additions & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "next/core-web-vitals",
"root": true
}
33 changes: 33 additions & 0 deletions .github/workflows/nextjs-ci.yml
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ out/
next-env.d.ts.bak
tsconfig.tsbuildinfo

# Test coverage reports
coverage/

# Node
node_modules/
package-lock.json
Expand Down
4 changes: 2 additions & 2 deletions api/_telemetry_static.json
Original file line number Diff line number Diff line change
@@ -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"
}
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
"name": "runbook-orchestrator-dashboard",
"version": "1.0.0",
"private": true,
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"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",
Expand Down Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions scripts/compute_telemetry_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
}
)

Expand All @@ -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.
Expand Down
61 changes: 61 additions & 0 deletions src/lib/api.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
136 changes: 136 additions & 0 deletions src/lib/hooks.more.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions src/lib/project.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Loading