diff --git a/tests/unit/agent-skills-page.test.tsx b/tests/unit/agent-skills-page.test.tsx new file mode 100644 index 0000000000..656eb75f00 --- /dev/null +++ b/tests/unit/agent-skills-page.test.tsx @@ -0,0 +1,631 @@ +// @vitest-environment jsdom +import React from "react"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Root } from "react-dom/client"; +import type { AgentSkill, SkillCoverage } from "../../src/lib/agentSkills/types"; + +// ── i18n stub ──────────────────────────────────────────────────────────────── +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +// ── next/link stub ─────────────────────────────────────────────────────────── +vi.mock("next/link", () => ({ + default: ({ + href, + children, + className, + }: { + href: string; + children: React.ReactNode; + className?: string; + }) => ( + + {children} + + ), +})); + +// ── next/dynamic stub — renders placeholder immediately ─────────────────────── +vi.mock("next/dynamic", () => ({ + default: (loader: () => Promise<{ default: React.ComponentType<{ children: string }> }>, _opts?: unknown) => { + // Return a synchronous stub that renders children as plain text. + return function DynamicStub({ children }: { children: string }) { + return
{children}
; + }; + }, +})); + +// ── Fixture helpers ────────────────────────────────────────────────────────── + +function makeSkill(overrides: Partial = {}): AgentSkill { + return { + id: "omni-providers", + name: "Providers", + description: "Manage provider connections and API keys.", + category: "api", + area: "providers", + icon: "hub", + endpoints: ["POST /api/providers", "GET /api/providers"], + rawUrl: "https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/omni-providers/SKILL.md", + githubUrl: "https://github.com/diegosouzapw/OmniRoute/blob/main/skills/omni-providers/SKILL.md", + ...overrides, + }; +} + +function make42Skills(): AgentSkill[] { + const skills: AgentSkill[] = []; + for (let i = 0; i < 22; i++) { + skills.push( + makeSkill({ + id: `omni-skill-${i}`, + name: `API Skill ${i}`, + category: "api", + }), + ); + } + for (let i = 0; i < 20; i++) { + skills.push( + makeSkill({ + id: `cli-skill-${i}`, + name: `CLI Skill ${i}`, + category: "cli", + endpoints: undefined, + cliCommands: [`skill${i} run`, `skill${i} status`], + }), + ); + } + return skills; +} + +const FULL_COVERAGE: SkillCoverage = { + api: { have: 22, total: 22 }, + cli: { have: 20, total: 20 }, + totalSkills: 42, + generatedAt: new Date().toISOString(), +}; + +const PARTIAL_COVERAGE: SkillCoverage = { + api: { have: 10, total: 22 }, + cli: { have: 8, total: 20 }, + totalSkills: 18, + generatedAt: new Date().toISOString(), +}; + +// ── Fetch mock factory ─────────────────────────────────────────────────────── + +function mockFetch(skills: AgentSkill[], coverage: SkillCoverage, rawMarkdown = "# Test Skill\nContent here.") { + return vi.fn(async (url: string | Request) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr === "/api/agent-skills") { + return new Response(JSON.stringify({ skills, coverage }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (/\/api\/agent-skills\/.+\/raw/.test(urlStr)) { + return new Response(JSON.stringify({ body: rawMarkdown }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + return new Response(JSON.stringify({}), { status: 404 }); + }); +} + +// ── Test setup ─────────────────────────────────────────────────────────────── + +const cleanupCallbacks: Array<() => void> = []; +let root: Root | null = null; + +function makeContainer(): HTMLElement { + const container = document.createElement("div"); + document.body.appendChild(container); + cleanupCallbacks.push(() => container.remove()); + return container; +} + +beforeEach(() => { + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + // Mock clipboard + Object.defineProperty(navigator, "clipboard", { + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + configurable: true, + }); + // Mock window.location.origin + Object.defineProperty(window, "location", { + value: { origin: "http://localhost:20128" }, + configurable: true, + }); + // Mock window.confirm + vi.spyOn(window, "confirm").mockReturnValue(false); +}); + +afterEach(async () => { + if (root) { + await act(async () => { + root?.unmount(); + }); + root = null; + } + while (cleanupCallbacks.length > 0) { + cleanupCallbacks.pop()?.(); + } + document.body.innerHTML = ""; + vi.clearAllMocks(); + vi.unstubAllGlobals(); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe("AgentSkillsPageClient", () => { + it("renders 42 skill cards after fetch resolves", async () => { + const skills = make42Skills(); + vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const cards = container.querySelectorAll("[data-testid^='skill-card-']"); + expect(cards.length).toBe(42); + }); + + it("renders SkillsConceptCard variant=agent at the top", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + // SkillsConceptCard uses i18n key conceptCard.agent.title + expect(container.textContent).toContain("conceptCard.agent.title"); + }); + + it("filter API shows only api-category cards", async () => { + const skills = make42Skills(); + vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const filterApiBtn = container.querySelector("[data-testid='filter-api']") as HTMLButtonElement | null; + expect(filterApiBtn).not.toBeNull(); + + await act(async () => { + filterApiBtn?.click(); + }); + + const cards = container.querySelectorAll("[data-testid^='skill-card-']"); + expect(cards.length).toBe(22); + }); + + it("filter CLI shows only cli-category cards", async () => { + const skills = make42Skills(); + vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const filterCliBtn = container.querySelector("[data-testid='filter-cli']") as HTMLButtonElement | null; + await act(async () => { + filterCliBtn?.click(); + }); + + const cards = container.querySelectorAll("[data-testid^='skill-card-']"); + expect(cards.length).toBe(20); + }); + + it("clicking a card triggers preview fetch", async () => { + const skills = make42Skills(); + const fetchMock = mockFetch(skills, FULL_COVERAGE, "# omni-skill-0 doc"); + vi.stubGlobal("fetch", fetchMock); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const firstCard = container.querySelector("[data-testid='skill-card-omni-skill-0']") as HTMLElement | null; + expect(firstCard).not.toBeNull(); + + await act(async () => { + firstCard?.click(); + }); + + // A raw fetch should have been made + const rawFetchCalls = (fetchMock as ReturnType).mock.calls.filter( + ([url]: [string]) => typeof url === "string" && url.includes("/raw"), + ); + expect(rawFetchCalls.length).toBeGreaterThan(0); + }); + + it("preview pane shows empty state when no card is selected", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const emptyState = container.querySelector("[data-testid='skill-preview-empty']"); + expect(emptyState).not.toBeNull(); + }); + + it("CoverageBar is rendered with 100% = green bars when coverage is full", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const coverageBar = container.querySelector("[data-testid='coverage-bar']"); + expect(coverageBar).not.toBeNull(); + + const progressBars = container.querySelectorAll("[role='progressbar']"); + expect(progressBars.length).toBe(2); + + // API bar — 22/22 = 100%, should have emerald color class + const apiBar = progressBars[0] as HTMLElement; + expect(apiBar.className).toContain("bg-emerald-500"); + + // CLI bar — 20/20 = 100%, should have emerald color class + const cliBar = progressBars[1] as HTMLElement; + expect(cliBar.className).toContain("bg-emerald-500"); + }); + + it("generate button is hidden when coverage is 100%", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const generateBtn = container.querySelector("[data-testid='generate-button']"); + expect(generateBtn).toBeNull(); + }); + + it("generate button is visible when coverage is partial", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), PARTIAL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const generateBtn = container.querySelector("[data-testid='generate-button']"); + expect(generateBtn).not.toBeNull(); + }); + + it("search filters cards by name", async () => { + const skills = make42Skills(); + vi.stubGlobal("fetch", mockFetch(skills, FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const searchInput = container.querySelector("[data-testid='search-input']") as HTMLInputElement | null; + expect(searchInput).not.toBeNull(); + + await act(async () => { + if (searchInput) { + searchInput.value = "API Skill 0"; + searchInput.dispatchEvent(new Event("input", { bubbles: true })); + // React uses onChange + const nativeInputValueSetter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + nativeInputValueSetter?.call(searchInput, "API Skill 0"); + searchInput.dispatchEvent(new Event("change", { bubbles: true })); + } + }); + + // After search, cards with "API Skill 0" in name should be visible + // (at minimum the one exact match) + const cards = container.querySelectorAll("[data-testid^='skill-card-']"); + expect(cards.length).toBeLessThanOrEqual(42); + }); + + it("MCP and A2A links bar is present", async () => { + vi.stubGlobal("fetch", mockFetch(make42Skills(), FULL_COVERAGE)); + + const { AgentSkillsPageClient } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/AgentSkillsPageClient" + ); + const container = makeContainer(); + root = createRoot(container); + await act(async () => { + root?.render(); + }); + + const linksBar = container.querySelector("[data-testid='mcp-a2a-links-bar']"); + expect(linksBar).not.toBeNull(); + }); +}); + +// ── CoverageBar isolated tests ─────────────────────────────────────────────── + +describe("CoverageBar", () => { + it("renders two progressbars with correct aria attributes", async () => { + const { CoverageBar } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render(); + }); + + const bars = container.querySelectorAll("[role='progressbar']"); + expect(bars.length).toBe(2); + + const apiBar = bars[0] as HTMLElement; + expect(apiBar.getAttribute("aria-valuenow")).toBe("22"); + expect(apiBar.getAttribute("aria-valuemax")).toBe("22"); + + const cliBar = bars[1] as HTMLElement; + expect(cliBar.getAttribute("aria-valuenow")).toBe("20"); + expect(cliBar.getAttribute("aria-valuemax")).toBe("20"); + + await act(async () => localRoot.unmount()); + }); + + it("applies red color class when coverage is below 75%", async () => { + const { CoverageBar } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar" + ); + const lowCoverage: SkillCoverage = { + api: { have: 5, total: 22 }, + cli: { have: 0, total: 20 }, + totalSkills: 5, + generatedAt: new Date().toISOString(), + }; + + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render(); + }); + + const bars = container.querySelectorAll("[role='progressbar']"); + const apiBar = bars[0] as HTMLElement; + expect(apiBar.className).toContain("bg-red-500"); + + await act(async () => localRoot.unmount()); + }); + + it("applies amber color class when coverage is between 75% and 100%", async () => { + const { CoverageBar } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/CoverageBar" + ); + const partialCoverage: SkillCoverage = { + api: { have: 18, total: 22 }, // ~81.8% = amber + cli: { have: 15, total: 20 }, // 75% = amber + totalSkills: 33, + generatedAt: new Date().toISOString(), + }; + + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render(); + }); + + const bars = container.querySelectorAll("[role='progressbar']"); + const apiBar = bars[0] as HTMLElement; + expect(apiBar.className).toContain("bg-amber-400"); + + await act(async () => localRoot.unmount()); + }); +}); + +// ── SkillCard isolated tests ───────────────────────────────────────────────── + +describe("SkillCard", () => { + it("renders skill name and description", async () => { + const { SkillCard } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard" + ); + const skill = makeSkill({ name: "Providers", description: "Manage connections" }); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( {}} />); + }); + + expect(container.textContent).toContain("Providers"); + expect(container.textContent).toContain("Manage connections"); + + await act(async () => localRoot.unmount()); + }); + + it("has role=button and aria-pressed=false when not selected", async () => { + const { SkillCard } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( {}} />); + }); + + const btn = container.querySelector("[role='button']") as HTMLElement | null; + expect(btn).not.toBeNull(); + expect(btn?.getAttribute("aria-pressed")).toBe("false"); + + await act(async () => localRoot.unmount()); + }); + + it("has aria-pressed=true when selected", async () => { + const { SkillCard } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( {}} />); + }); + + const btn = container.querySelector("[role='button']") as HTMLElement | null; + expect(btn?.getAttribute("aria-pressed")).toBe("true"); + + await act(async () => localRoot.unmount()); + }); + + it("calls onClick when clicked", async () => { + const { SkillCard } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard" + ); + const handleClick = vi.fn(); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render(); + }); + + const btn = container.querySelector("[role='button']") as HTMLElement | null; + await act(async () => btn?.click()); + expect(handleClick).toHaveBeenCalledTimes(1); + + await act(async () => localRoot.unmount()); + }); + + it("shows first 2 endpoints as chips for API skill", async () => { + const { SkillCard } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillCard" + ); + const skill = makeSkill({ + endpoints: ["POST /api/providers", "GET /api/providers", "DELETE /api/providers/:id"], + }); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( {}} />); + }); + + const chips = container.querySelectorAll("code"); + expect(chips.length).toBe(2); + expect(chips[0].textContent).toBe("POST /api/providers"); + expect(chips[1].textContent).toBe("GET /api/providers"); + + await act(async () => localRoot.unmount()); + }); +}); + +// ── SkillPreviewPane isolated tests ────────────────────────────────────────── + +describe("SkillPreviewPane", () => { + it("renders empty state when skillId is null", async () => { + const { SkillPreviewPane } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( + , + ); + }); + + const empty = container.querySelector("[data-testid='skill-preview-empty']"); + expect(empty).not.toBeNull(); + expect(container.textContent).toContain("previewEmpty"); + + await act(async () => localRoot.unmount()); + }); + + it("renders markdown when skillId and markdown are provided", async () => { + const { SkillPreviewPane } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( + , + ); + }); + + const preview = container.querySelector("[data-testid='skill-preview-pane']"); + expect(preview).not.toBeNull(); + expect(container.textContent).toContain("Providers"); + + await act(async () => localRoot.unmount()); + }); + + it("shows error state when skillId provided but markdown is empty string", async () => { + const { SkillPreviewPane } = await import( + "../../src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane" + ); + const container = makeContainer(); + const localRoot = createRoot(container); + await act(async () => { + localRoot.render( + , + ); + }); + + // markdown is "" (falsy) — should show error state + const errorEl = container.querySelector("[data-testid='skill-preview-error']"); + expect(errorEl).not.toBeNull(); + + await act(async () => localRoot.unmount()); + }); +});