+
+ {t("categoryApi")} {api.have}/{api.total}
+
+
+
0 ? (api.have / api.total) * 100 : 0}%` }}
+ />
+
+
+ {api.total > 0 ? Math.round((api.have / api.total) * 100) : 0}%
+
+
+
+
+
+ {t("categoryCli")} {cli.have}/{cli.total}
+
+
+
0 ? (cli.have / cli.total) * 100 : 0}%` }}
+ />
+
+
+ {cli.total > 0 ? Math.round((cli.have / cli.total) * 100) : 0}%
+
+
+
+ );
+}
+
+export default CoverageBar;
diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx
new file mode 100644
index 0000000000..cddb9cb6dd
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/agent-skills/components/McpA2aLinksBar.tsx
@@ -0,0 +1,93 @@
+"use client";
+
+import { useCallback, useState, useSyncExternalStore } from "react";
+import { useTranslations } from "next-intl";
+
+// SSR-safe origin via useSyncExternalStore.
+// Server snapshot returns "" (avoids hydration mismatch).
+function useOrigin(): string {
+ return useSyncExternalStore(
+ () => () => {}, // no external subscription needed
+ () => (typeof window !== "undefined" ? window.location.origin : ""),
+ () => "", // server snapshot
+ );
+}
+
+interface LinkCardProps {
+ label: string;
+ url: string;
+ icon: string;
+ prompt: string;
+}
+
+function LinkCard({ label, url, icon, prompt }: LinkCardProps): JSX.Element {
+ const t = useTranslations("agentSkills");
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = useCallback(async () => {
+ try {
+ await navigator.clipboard.writeText(url);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch {
+ // clipboard not available — silently ignore
+ }
+ }, [url]);
+
+ return (
+
+
+ {icon}
+
+
+
+ {label}
+
+
+
{url}
+
{prompt}
+
+
+ );
+}
+
+export function McpA2aLinksBar(): JSX.Element {
+ const t = useTranslations("agentSkills");
+ const origin = useOrigin();
+
+ const mcpUrl = origin ? `${origin}/api/mcp/sse` : "/api/mcp/sse";
+ const a2aUrl = origin ? `${origin}/.well-known/agent.json` : "/.well-known/agent.json";
+
+ return (
+
+
+
+
+ );
+}
+
+export default McpA2aLinksBar;
diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx
new file mode 100644
index 0000000000..aab1ac205f
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillCard.tsx
@@ -0,0 +1,105 @@
+"use client";
+
+import { useCallback } from "react";
+import { useTranslations } from "next-intl";
+import type { AgentSkill } from "@/lib/agentSkills/types";
+
+interface SkillCardProps {
+ skill: AgentSkill;
+ selected: boolean;
+ onClick: () => void;
+}
+
+export function SkillCard({ skill, selected, onClick }: SkillCardProps): JSX.Element {
+ const t = useTranslations("agentSkills");
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent
) => {
+ if (e.key === "Enter" || e.key === " ") {
+ e.preventDefault();
+ onClick();
+ }
+ },
+ [onClick],
+ );
+
+ const previewItems: string[] =
+ skill.category === "api"
+ ? (skill.endpoints ?? []).slice(0, 2)
+ : (skill.cliCommands ?? []).slice(0, 2);
+
+ return (
+
+
+
+ {skill.icon ?? "article"}
+
+
+
+
+
+ {skill.name}
+
+
+ {skill.category === "api" ? t("categoryApi") : t("categoryCli")}
+
+
+ {skill.isEntry && (
+
+ start
+
+ )}
+
+ {skill.isNew && (
+
+ new
+
+ )}
+
+
+
{skill.description}
+
+ {previewItems.length > 0 && (
+
+ {previewItems.map((item) => (
+
+ {item}
+
+ ))}
+
+ )}
+
+
+ );
+}
+
+export default SkillCard;
diff --git a/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx
new file mode 100644
index 0000000000..e73ff67157
--- /dev/null
+++ b/src/app/(dashboard)/dashboard/agent-skills/components/SkillPreviewPane.tsx
@@ -0,0 +1,147 @@
+"use client";
+
+import { useCallback } from "react";
+import dynamic from "next/dynamic";
+import { useTranslations } from "next-intl";
+
+// Lazy-load react-markdown to reduce initial bundle size.
+const ReactMarkdown = dynamic(() => import("react-markdown"), {
+ loading: () => ,
+});
+
+// remark-gfm loaded lazily alongside ReactMarkdown via remarkPlugins prop.
+// We avoid rehype-raw to prevent XSS (Hard Rule #7 context).
+
+interface SkillPreviewPaneProps {
+ skillId: string | null;
+ markdown: string | null;
+ loading: boolean;
+ onRefresh?: () => void;
+}
+
+function SkeletonLines({ lines }: { lines: number }): JSX.Element {
+ return (
+
+ {Array.from({ length: lines }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+export function SkillPreviewPane({
+ skillId,
+ markdown,
+ loading,
+ onRefresh,
+}: SkillPreviewPaneProps): JSX.Element {
+ const t = useTranslations("agentSkills");
+
+ const handleCopyRawUrl = useCallback(async () => {
+ if (!skillId) return;
+ const rawUrl = `https://raw.githubusercontent.com/diegosouzapw/OmniRoute/refs/heads/main/skills/${skillId}/SKILL.md`;
+ try {
+ await navigator.clipboard.writeText(rawUrl);
+ } catch {
+ // clipboard not available — silently ignore
+ }
+ }, [skillId]);
+
+ const githubUrl = skillId
+ ? `https://github.com/diegosouzapw/OmniRoute/blob/main/skills/${skillId}/SKILL.md`
+ : null;
+
+ // Empty state
+ if (!skillId) {
+ return (
+
+
+ article
+
+
{t("previewEmpty")}
+
+ );
+ }
+
+ return (
+
+ {/* Header */}
+
+
+ {skillId}/SKILL.md
+
+
+ {onRefresh && (
+
+ )}
+
+ {githubUrl && (
+
+ open_in_new
+
+ )}
+
+
+
+ {/* Content */}
+
+ {loading ? (
+
+ ) : markdown ? (
+
+ {markdown}
+
+ ) : (
+
+ error
+ {t("previewError")}
+
+ )}
+
+
+ );
+}
+
+export default SkillPreviewPane;
diff --git a/src/app/(dashboard)/dashboard/agent-skills/page.tsx b/src/app/(dashboard)/dashboard/agent-skills/page.tsx
index af5de938a3..5dc69cd1e0 100644
--- a/src/app/(dashboard)/dashboard/agent-skills/page.tsx
+++ b/src/app/(dashboard)/dashboard/agent-skills/page.tsx
@@ -1,172 +1,5 @@
-"use client";
+import { AgentSkillsPageClient } from "./AgentSkillsPageClient";
-import { useTranslations } from "next-intl";
-import {
- AGENT_SKILLS,
- AGENT_SKILLS_REPO_URL,
- getAgentSkillRawUrl,
- getAgentSkillBlobUrl,
- type AgentSkill,
-} from "@/shared/constants/agentSkills";
-import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
-
-function CopyButton({ url }: { url: string }) {
- const t = useTranslations("agents");
- const { copied, copy } = useCopyToClipboard();
- const isCopied = copied === url;
-
- return (
-
- );
-}
-
-function SkillRow({ skill }: { skill: AgentSkill }) {
- const t = useTranslations("agents");
- const rawUrl = getAgentSkillRawUrl(skill.id);
- const blobUrl = getAgentSkillBlobUrl(skill.id);
-
- return (
-
-
- {skill.icon}
-
-
-
-
- {skill.name}
- {skill.isEntry && (
-
- {t("startHere")}
-
- )}
- {skill.isNew && (
-
- {t("badgeNew")}
-
- )}
- {skill.endpoint && (
-
- {skill.endpoint}
-
- )}
-
-
{skill.description}
-
-
-
-
- );
-}
-
-function SkillSection({
- title,
- subtitle,
- icon,
- skills,
-}: {
- title: string;
- subtitle: string;
- icon: string;
- skills: AgentSkill[];
-}) {
- return (
-
-
-
- {skills.map((skill) => (
-
- ))}
-
-
- );
-}
-
-export default function AgentSkillsPage() {
- const t = useTranslations("agents");
- const apiSkills = AGENT_SKILLS.filter((s) => s.category === "api");
- const cliSkills = AGENT_SKILLS.filter((s) => s.category === "cli");
-
- return (
-
- {/* How to use — full width */}
-
-
- info
- {t("howToUse")}
-
-
- -
- 1.{" "}
- {t.rich("howToUseStep1", {
- copyUrl: t("copyUrl"),
- bold: (chunks) => {chunks},
- })}
-
- -
- 2. {t("howToUseStep2")}
-
-
- {t("howToUseStep2Code")}
-
-
- - 3. {t("howToUseStep3")}
-
-
- open_in_new
- {t("browseAllSkillsOnGithub")}
-
-
-
- {/* Two-column grid: API Skills | CLI Skills */}
-
-
-
-
-
- );
+export default function Page() {
+ return ;
}
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());
+ });
+});