[a2a9f601] Panel test gate, baseline vitest tests, and CI enforcement (#237) (#239)

* [05d580eb] test(panel): add baseline vitest tests for 5 source units and widen coverage include (#235)

- panel/src/lib/__tests__/agent-definitions.test.ts: all 7 filter functions covered
  (getBoardAgents, getMainPm, getBackend/Frontend/Ux/Marketing/OnDemandAgents)
  including null/undefined input and CEO/MAIN_PM exclusion logic

- panel/src/lib/__tests__/client.test.ts: getErrorMessage fully covered
  (ECONNABORTED, ERR_NETWORK, string/array/object detail formats,
   HTTP 401/403/404/422/500+, plain Error fallback, unknown input fallback)

- panel/src/store/__tests__/notifications-store.test.ts: useNotificationStore
  (addNotification counter+dedup, markAsRead, markAsAcknowledged, setCounts, clearAll)

- panel/src/store/__tests__/rate-limit-store.test.ts: useRateLimitStore
  (hitRateLimit entry+resumeAt, liftRateLimit deletion, syncFromApi replacement)

- panel/src/lib/__tests__/websocket.test.ts: getWebSocketUrl
  (absolute ws://, absolute wss://, http→ws: relative, https→wss: relative, SSR fallback)

- panel/vitest.config.ts: coverage include widened to src/lib/**, src/store/**,
  src/components/** and global thresholds removed (baseline tests cover only 5 units
  of hundreds; thresholds will be re-added per-file as coverage grows)

pnpm test: 7 test files, 111 tests, 0 failures
pnpm lint: clean
pnpm typecheck: clean



* [1bc8195e] feat(ci): add panel-gate and panel-quality Makefile targets and CI test step (#236)

Add two new .PHONY Makefile targets (panel-gate, panel-quality) that run
pnpm lint, pnpm exec tsc --noEmit, and pnpm test inside the panel directory.
panel-quality depends on panel-gate so a single target drives the full gate.

Add a 'Test (vitest + coverage)' step to the panel job in ci.yml, placed
after the existing Type-check step. The job's default working-directory is
already panel so no override is needed; vitest.config.ts text reporter
prints coverage to stdout automatically.



---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Frontend Developer 2 <fe-dev-2@agents.roboco.dev>
This commit is contained in:
Renzo F
2026-06-20 20:25:38 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Frontend Developer 2
parent 028b49161b
commit bed8342e7b
8 changed files with 1084 additions and 7 deletions
@@ -0,0 +1,273 @@
import { describe, it, expect } from "vitest";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
getMarketingAgents,
getOnDemandAgents,
type AgentDefinition,
} from "@/lib/agent-definitions";
import { AgentRole, Team } from "@/types";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
const makeAgent = (
id: string,
role: AgentRole | null,
team: Team | null
): AgentDefinition => ({ id, name: id, role, team });
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const ceo = makeAgent("ceo-1", AgentRole.CEO, Team.BOARD);
const mainPm = makeAgent("main-pm-1", AgentRole.MAIN_PM, Team.MAIN_PM);
const auditor = makeAgent("auditor-1", AgentRole.AUDITOR, Team.BOARD);
const headMarketing = makeAgent("hm-1", AgentRole.HEAD_MARKETING, null);
const productOwner = makeAgent("po-1", AgentRole.PRODUCT_OWNER, Team.BOARD);
const prReviewer = makeAgent("prr-1", AgentRole.PR_REVIEWER, null);
const beCellPm = makeAgent("be-pm", AgentRole.CELL_PM, Team.BACKEND);
const beDev1 = makeAgent("be-dev-1", AgentRole.DEVELOPER, Team.BACKEND);
const feDev1 = makeAgent("fe-dev-1", AgentRole.DEVELOPER, Team.FRONTEND);
const uxDev = makeAgent("ux-1", AgentRole.QA, Team.UX_UI);
const mktDev = makeAgent("mkt-1", AgentRole.DOCUMENTER, Team.MARKETING);
const prompter = makeAgent("prompter-1", AgentRole.PROMPTER, null);
const secretary = makeAgent("secretary-1", AgentRole.SECRETARY, null);
const ALL_AGENTS: AgentDefinition[] = [
ceo,
mainPm,
auditor,
headMarketing,
productOwner,
prReviewer,
beCellPm,
beDev1,
feDev1,
uxDev,
mktDev,
prompter,
secretary,
];
// ---------------------------------------------------------------------------
// getBoardAgents
// ---------------------------------------------------------------------------
describe("getBoardAgents", () => {
it("includes agents on BOARD team (excluding CEO and MAIN_PM)", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).toContainEqual(auditor);
expect(result).toContainEqual(productOwner);
});
it("excludes CEO even though CEO has team=board", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).not.toContainEqual(ceo);
});
it("excludes MAIN_PM (MAIN_PM has its own dedicated section)", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).not.toContainEqual(mainPm);
});
it("includes HEAD_MARKETING role regardless of team", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).toContainEqual(headMarketing);
});
it("includes PR_REVIEWER role regardless of team", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).toContainEqual(prReviewer);
});
it("excludes cell agents (BACKEND, FRONTEND, etc.)", () => {
const result = getBoardAgents(ALL_AGENTS);
expect(result).not.toContainEqual(beDev1);
expect(result).not.toContainEqual(feDev1);
});
it("returns an empty array for null input", () => {
expect(getBoardAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getBoardAgents(undefined)).toEqual([]);
});
it("returns an empty array for an empty agent list", () => {
expect(getBoardAgents([])).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getMainPm
// ---------------------------------------------------------------------------
describe("getMainPm", () => {
it("returns agents with MAIN_PM role", () => {
const result = getMainPm(ALL_AGENTS);
expect(result).toContainEqual(mainPm);
expect(result).toHaveLength(1);
});
it("excludes agents without MAIN_PM role", () => {
const result = getMainPm(ALL_AGENTS);
expect(result).not.toContainEqual(ceo);
expect(result).not.toContainEqual(beDev1);
});
it("returns an empty array for null input", () => {
expect(getMainPm(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getMainPm(undefined)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getBackendAgents
// ---------------------------------------------------------------------------
describe("getBackendAgents", () => {
it("returns all agents on the BACKEND team", () => {
const result = getBackendAgents(ALL_AGENTS);
expect(result).toContainEqual(beDev1);
expect(result).toContainEqual(beCellPm);
});
it("excludes agents from other teams", () => {
const result = getBackendAgents(ALL_AGENTS);
expect(result).not.toContainEqual(feDev1);
expect(result).not.toContainEqual(uxDev);
});
it("returns an empty array for null input", () => {
expect(getBackendAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getBackendAgents(undefined)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getFrontendAgents
// ---------------------------------------------------------------------------
describe("getFrontendAgents", () => {
it("returns all agents on the FRONTEND team", () => {
const result = getFrontendAgents(ALL_AGENTS);
expect(result).toContainEqual(feDev1);
expect(result).toHaveLength(1);
});
it("excludes agents from other teams", () => {
const result = getFrontendAgents(ALL_AGENTS);
expect(result).not.toContainEqual(beDev1);
});
it("returns an empty array for null input", () => {
expect(getFrontendAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getFrontendAgents(undefined)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getUxAgents
// ---------------------------------------------------------------------------
describe("getUxAgents", () => {
it("returns all agents on the UX_UI team", () => {
const result = getUxAgents(ALL_AGENTS);
expect(result).toContainEqual(uxDev);
expect(result).toHaveLength(1);
});
it("excludes agents from other teams", () => {
const result = getUxAgents(ALL_AGENTS);
expect(result).not.toContainEqual(beDev1);
expect(result).not.toContainEqual(feDev1);
});
it("returns an empty array for null input", () => {
expect(getUxAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getUxAgents(undefined)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getMarketingAgents
// ---------------------------------------------------------------------------
describe("getMarketingAgents", () => {
it("returns all agents on the MARKETING team", () => {
const result = getMarketingAgents(ALL_AGENTS);
expect(result).toContainEqual(mktDev);
expect(result).toHaveLength(1);
});
it("excludes agents from other teams", () => {
const result = getMarketingAgents(ALL_AGENTS);
expect(result).not.toContainEqual(beDev1);
});
it("returns an empty array for null input", () => {
expect(getMarketingAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getMarketingAgents(undefined)).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// getOnDemandAgents
// ---------------------------------------------------------------------------
describe("getOnDemandAgents", () => {
it("returns PROMPTER agents", () => {
const result = getOnDemandAgents(ALL_AGENTS);
expect(result).toContainEqual(prompter);
});
it("returns SECRETARY agents", () => {
const result = getOnDemandAgents(ALL_AGENTS);
expect(result).toContainEqual(secretary);
});
it("excludes agents that are neither PROMPTER nor SECRETARY", () => {
const result = getOnDemandAgents(ALL_AGENTS);
expect(result).not.toContainEqual(beDev1);
expect(result).not.toContainEqual(ceo);
expect(result).not.toContainEqual(mainPm);
});
it("returns both on-demand roles and no others", () => {
const result = getOnDemandAgents(ALL_AGENTS);
expect(result).toHaveLength(2);
expect(result.map((a) => a.role)).toEqual(
expect.arrayContaining([AgentRole.PROMPTER, AgentRole.SECRETARY])
);
});
it("returns an empty array for null input", () => {
expect(getOnDemandAgents(null)).toEqual([]);
});
it("returns an empty array for undefined input", () => {
expect(getOnDemandAgents(undefined)).toEqual([]);
});
});
+215
View File
@@ -0,0 +1,215 @@
/**
* Tests for getErrorMessage() from @/lib/api/client.
*
* Strategy: We construct minimal objects that satisfy the AxiosError contract.
* The real `axios.isAxiosError` implementation checks:
* isObject(payload) && payload.isAxiosError === true
* so plain objects with { isAxiosError: true } are recognised correctly,
* and we need not fully mock the axios module.
*
* We still mock the modules that client.ts imports for side effects so the
* axios instance creation and interceptor registration don't produce
* unwanted network calls or store mutations during the test run.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
// ---------------------------------------------------------------------------
// Mock heavy side-effect imports consumed by client.ts at module level
// ---------------------------------------------------------------------------
vi.mock("sonner", () => ({
toast: { warning: vi.fn(), error: vi.fn() },
}));
vi.mock("@/store/rate-limit-store", () => ({
useRateLimitStore: {
getState: vi.fn(() => ({ hitRateLimit: vi.fn() })),
},
}));
// ---------------------------------------------------------------------------
// Import the function under test AFTER mocks are in place
// ---------------------------------------------------------------------------
import { getErrorMessage } from "@/lib/api/client";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Build a minimal AxiosError-like object accepted by `axios.isAxiosError`.
*
* The real axios checks: isObject(payload) && payload.isAxiosError === true
*/
function makeAxiosError(opts: {
code?: string;
status?: number;
detail?: unknown;
message?: string;
}) {
return {
isAxiosError: true as const,
code: opts.code,
message: opts.message ?? "axios error",
response: opts.status !== undefined
? {
status: opts.status,
data: { detail: opts.detail } as { detail?: unknown },
headers: {} as Record<string, string>,
config: {} as never,
statusText: "",
}
: undefined,
config: {} as never,
name: "AxiosError" as const,
toJSON: () => ({}),
};
}
beforeEach(() => {
vi.clearAllMocks();
});
// ---------------------------------------------------------------------------
// Error-code tests
// ---------------------------------------------------------------------------
describe("getErrorMessage — error codes", () => {
it("returns timeout message for ECONNABORTED", () => {
const err = makeAxiosError({ code: "ECONNABORTED" });
expect(getErrorMessage(err)).toBe(
"Request timed out. The server may be busy."
);
});
it("returns network message for ERR_NETWORK", () => {
const err = makeAxiosError({ code: "ERR_NETWORK" });
expect(getErrorMessage(err)).toBe(
"Cannot connect to server. Check if the backend is running."
);
});
});
// ---------------------------------------------------------------------------
// `detail` payload format tests (formatErrorDetail branches)
// ---------------------------------------------------------------------------
describe("getErrorMessage — detail string format", () => {
it("returns the string detail directly", () => {
const err = makeAxiosError({ status: 400, detail: "Some error string" });
expect(getErrorMessage(err)).toBe("Some error string");
});
});
describe("getErrorMessage — detail array format", () => {
it("formats a FastAPI validation array into a readable string", () => {
const err = makeAxiosError({
status: 422,
detail: [
{ loc: ["body", "name"], msg: "field required" },
{ loc: ["body", "email"], msg: "invalid email" },
],
});
const result = getErrorMessage(err);
// Both validation errors should appear
expect(result).toContain("name");
expect(result).toContain("field required");
expect(result).toContain("email");
expect(result).toContain("invalid email");
});
it("returns a plain string item inside the array as-is", () => {
const err = makeAxiosError({
status: 400,
detail: ["plain error"],
});
expect(getErrorMessage(err)).toBe("plain error");
});
});
describe("getErrorMessage — detail object format", () => {
it("extracts `message` field from a structured error object", () => {
const err = makeAxiosError({
status: 400,
detail: { message: "Structured message" },
});
expect(getErrorMessage(err)).toBe("Structured message");
});
it("extracts `error` field when `message` is absent", () => {
const err = makeAxiosError({
status: 400,
detail: { error: "Error field content" },
});
expect(getErrorMessage(err)).toBe("Error field content");
});
});
// ---------------------------------------------------------------------------
// HTTP status code tests (no usable detail)
// ---------------------------------------------------------------------------
describe("getErrorMessage — HTTP status codes", () => {
it("returns auth message for 401", () => {
const err = makeAxiosError({ status: 401 });
expect(getErrorMessage(err)).toBe(
"Authentication required. Please refresh the page."
);
});
it("returns permission message for 403", () => {
const err = makeAxiosError({ status: 403 });
expect(getErrorMessage(err)).toBe("Permission denied for this action.");
});
it("returns not-found message for 404", () => {
const err = makeAxiosError({ status: 404 });
expect(getErrorMessage(err)).toBe(
"The requested resource was not found."
);
});
it("returns validation message for 422 without detail", () => {
const err = makeAxiosError({ status: 422 });
expect(getErrorMessage(err)).toBe("Invalid request data.");
});
it("returns server-error message for 500", () => {
const err = makeAxiosError({ status: 500 });
expect(getErrorMessage(err)).toBe("Server error. Please try again later.");
});
it("returns server-error message for 502 (>= 500)", () => {
const err = makeAxiosError({ status: 502 });
expect(getErrorMessage(err)).toBe("Server error. Please try again later.");
});
});
// ---------------------------------------------------------------------------
// Fallback tests (non-Axios errors)
// ---------------------------------------------------------------------------
describe("getErrorMessage — non-Axios fallbacks", () => {
it("returns Error.message for a plain Error instance", () => {
const err = new Error("plain JS error");
expect(getErrorMessage(err)).toBe("plain JS error");
});
it("returns generic message for a string input", () => {
expect(getErrorMessage("some string")).toBe("An unexpected error occurred");
});
it("returns generic message for null input", () => {
expect(getErrorMessage(null)).toBe("An unexpected error occurred");
});
it("returns generic message for undefined input", () => {
expect(getErrorMessage(undefined)).toBe("An unexpected error occurred");
});
it("returns generic message for a plain object without isAxiosError", () => {
expect(getErrorMessage({ code: "SOME_CODE" })).toBe(
"An unexpected error occurred"
);
});
});
+117
View File
@@ -0,0 +1,117 @@
/**
* Tests for getWebSocketUrl() from @/lib/websocket/connection.
*
* Because `WS_URL` is a named import captured at module-load time, we use
* vi.resetModules() + vi.doMock() + dynamic import() in each test so that
* every test gets a fresh module binding with the desired constant value.
*
* window.location is stubbed via vi.stubGlobal() and cleaned up in
* afterEach with vi.unstubAllGlobals().
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// ---------------------------------------------------------------------------
// Shared constants mock factory
// ---------------------------------------------------------------------------
function mockConstants(wsUrl: string) {
vi.doMock("@/lib/constants", () => ({
WS_URL: wsUrl,
API_URL: "/api",
CEO_AGENT_ID: "00000000-0000-0000-0000-000000000001",
CEO_ROLE: "ceo",
DEFAULT_PAGE_SIZE: 20,
MAX_PAGE_SIZE: 100,
WS_RECONNECT_INTERVAL: 5000,
WS_MAX_RECONNECT_ATTEMPTS: 3,
WS_HEARTBEAT_INTERVAL: 30000,
STREAM_MAX_MESSAGES: 100,
NOTIFICATION_MAX_DISPLAY: 10,
}));
}
beforeEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
afterEach(() => {
vi.resetModules();
vi.unstubAllGlobals();
});
// ---------------------------------------------------------------------------
// Absolute URL passthrough
// ---------------------------------------------------------------------------
describe("getWebSocketUrl — absolute ws:// passthrough", () => {
it("returns the absolute ws:// URL unchanged", async () => {
const absoluteUrl = "ws://direct.example.com/ws";
mockConstants(absoluteUrl);
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
expect(getWebSocketUrl()).toBe(absoluteUrl);
});
});
describe("getWebSocketUrl — absolute wss:// passthrough", () => {
it("returns the absolute wss:// URL unchanged", async () => {
const absoluteUrl = "wss://secure.example.com/ws";
mockConstants(absoluteUrl);
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
expect(getWebSocketUrl()).toBe(absoluteUrl);
});
});
// ---------------------------------------------------------------------------
// Relative-path construction via window.location
// ---------------------------------------------------------------------------
describe("getWebSocketUrl — relative-path http → ws:", () => {
it("maps http: protocol to ws: and appends the relative WS_URL", async () => {
mockConstants("/ws");
vi.stubGlobal("location", {
protocol: "http:",
host: "myhost.local:3000",
});
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
expect(getWebSocketUrl()).toBe("ws://myhost.local:3000/ws");
});
});
describe("getWebSocketUrl — relative-path https → wss:", () => {
it("maps https: protocol to wss: and appends the relative WS_URL", async () => {
mockConstants("/ws");
vi.stubGlobal("location", {
protocol: "https:",
host: "secure.example.com",
});
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
expect(getWebSocketUrl()).toBe("wss://secure.example.com/ws");
});
it("prepends a leading slash when WS_URL does not start with /", async () => {
mockConstants("ws");
vi.stubGlobal("location", {
protocol: "https:",
host: "secure.example.com",
});
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
// The function does: `/${WS_URL}` when WS_URL doesn't start with /
expect(getWebSocketUrl()).toBe("wss://secure.example.com/ws");
});
});
// ---------------------------------------------------------------------------
// SSR fallback (window undefined)
// ---------------------------------------------------------------------------
describe("getWebSocketUrl — SSR fallback", () => {
it("returns the raw WS_URL when window is not available", async () => {
mockConstants("/ws");
// Remove window to simulate server-side rendering
vi.stubGlobal("window", undefined);
const { getWebSocketUrl } = await import("@/lib/websocket/connection");
expect(getWebSocketUrl()).toBe("/ws");
});
});