mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 12:52:11 +03:00
merge(F7): integrate W3 frente F7 into base
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { SkillsConceptCard } from "@/shared/components/SkillsConceptCard";
|
||||
import { CoverageBar } from "./components/CoverageBar";
|
||||
import { McpA2aLinksBar } from "./components/McpA2aLinksBar";
|
||||
import { SkillCard } from "./components/SkillCard";
|
||||
import { SkillPreviewPane } from "./components/SkillPreviewPane";
|
||||
import type { AgentSkill, SkillCoverage } from "@/lib/agentSkills/types";
|
||||
|
||||
type FilterCategory = "all" | "api" | "cli";
|
||||
|
||||
// ── Skeleton helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
function SkillCardSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-3 animate-pulse">
|
||||
<div className="h-9 w-9 rounded-lg bg-bg-subtle shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-3 w-1/3 rounded bg-bg-subtle" />
|
||||
<div className="h-2 w-full rounded bg-bg-subtle" />
|
||||
<div className="h-2 w-4/5 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageBarSkeleton(): JSX.Element {
|
||||
return (
|
||||
<div className="space-y-2 animate-pulse">
|
||||
<div className="flex gap-2">
|
||||
<div className="h-2 w-16 rounded bg-bg-subtle" />
|
||||
<div className="flex-1 h-2 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-2 w-16 rounded bg-bg-subtle" />
|
||||
<div className="flex-1 h-2 rounded bg-bg-subtle" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export function AgentSkillsPageClient(): JSX.Element {
|
||||
const t = useTranslations("agentSkills");
|
||||
|
||||
// State
|
||||
const [catalog, setCatalog] = useState<AgentSkill[]>([]);
|
||||
const [coverage, setCoverage] = useState<SkillCoverage | null>(null);
|
||||
const [loadingCatalog, setLoadingCatalog] = useState(true);
|
||||
const [filter, setFilter] = useState<FilterCategory>("all");
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [markdownCache, setMarkdownCache] = useState<Map<string, string>>(new Map());
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [generatingSkills, setGeneratingSkills] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
// ── Fetch catalog + coverage on mount ────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setLoadingCatalog(true);
|
||||
const res = await fetch("/api/agent-skills", { signal: controller.signal });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { skills: AgentSkill[]; coverage: SkillCoverage };
|
||||
setCatalog(json.skills ?? []);
|
||||
setCoverage(json.coverage ?? null);
|
||||
} catch (err) {
|
||||
if ((err as Error).name !== "AbortError") {
|
||||
setCatalog([]);
|
||||
}
|
||||
} finally {
|
||||
setLoadingCatalog(false);
|
||||
}
|
||||
};
|
||||
|
||||
void load();
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Fetch raw markdown when a card is selected ────────────────────────────
|
||||
const loadPreview = useCallback(
|
||||
async (id: string) => {
|
||||
if (markdownCache.has(id)) return;
|
||||
setLoadingPreview(true);
|
||||
try {
|
||||
const res = await fetch(`/api/agent-skills/${id}/raw`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { body: string };
|
||||
setMarkdownCache((prev) => new Map(prev).set(id, json.body ?? ""));
|
||||
} catch {
|
||||
setMarkdownCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
// Set empty string to signal "failed" so we show error state
|
||||
next.set(id, "");
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setLoadingPreview(false);
|
||||
}
|
||||
},
|
||||
[markdownCache],
|
||||
);
|
||||
|
||||
const handleSelectCard = useCallback(
|
||||
(id: string) => {
|
||||
setSelectedId(id);
|
||||
void loadPreview(id);
|
||||
},
|
||||
[loadPreview],
|
||||
);
|
||||
|
||||
const handleRefreshPreview = useCallback(() => {
|
||||
if (!selectedId) return;
|
||||
setMarkdownCache((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(selectedId);
|
||||
return next;
|
||||
});
|
||||
void loadPreview(selectedId);
|
||||
}, [selectedId, loadPreview]);
|
||||
|
||||
// ── Generate missing skills ───────────────────────────────────────────────
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const confirmed = window.confirm(t("regenerateConfirm"));
|
||||
if (!confirmed) return;
|
||||
setGeneratingSkills(true);
|
||||
try {
|
||||
const res = await fetch("/api/agent-skills/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dryRun: false, prune: false }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
// Refresh catalog + coverage after generation
|
||||
const res2 = await fetch("/api/agent-skills");
|
||||
if (res2.ok) {
|
||||
const json = (await res2.json()) as { skills: AgentSkill[]; coverage: SkillCoverage };
|
||||
setCatalog(json.skills ?? []);
|
||||
setCoverage(json.coverage ?? null);
|
||||
}
|
||||
} catch {
|
||||
// Error handled silently — could extend with toast notification
|
||||
} finally {
|
||||
setGeneratingSkills(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// ── Filtering + search ────────────────────────────────────────────────────
|
||||
const filteredSkills = catalog.filter((s) => {
|
||||
if (filter !== "all" && s.category !== filter) return false;
|
||||
if (searchTerm.trim()) {
|
||||
const q = searchTerm.toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q) ||
|
||||
s.id.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const selectedMarkdown = selectedId ? markdownCache.get(selectedId) ?? null : null;
|
||||
const coverageTotal =
|
||||
coverage !== null ? coverage.api.have + coverage.cli.have : null;
|
||||
const showGenerateButton = coverageTotal !== null && coverageTotal < 42;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Concept card — full width */}
|
||||
<SkillsConceptCard variant="agent" />
|
||||
|
||||
{/* Header: coverage + MCP/A2A bar + generate button */}
|
||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-bg p-4">
|
||||
{/* Coverage */}
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs font-semibold text-text-muted uppercase tracking-wide">
|
||||
{t("coverageLabel")}
|
||||
</span>
|
||||
{showGenerateButton && (
|
||||
<button
|
||||
onClick={() => void handleGenerate()}
|
||||
disabled={generatingSkills}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-primary/30 bg-primary/5 px-3 py-1.5 text-xs font-medium text-primary hover:bg-primary/10 transition-colors disabled:opacity-60"
|
||||
data-testid="generate-button"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${generatingSkills ? "animate-spin" : ""}`}
|
||||
>
|
||||
{generatingSkills ? "refresh" : "auto_fix_high"}
|
||||
</span>
|
||||
{generatingSkills ? t("regenerateRunning") : t("generateButton")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{coverage ? (
|
||||
<CoverageBar coverage={coverage} />
|
||||
) : (
|
||||
<CoverageBarSkeleton />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* MCP + A2A links */}
|
||||
<McpA2aLinksBar />
|
||||
</div>
|
||||
|
||||
{/* Search + filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<div className="relative flex-1">
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 material-symbols-outlined text-[16px] text-text-muted pointer-events-none">
|
||||
search
|
||||
</span>
|
||||
<input
|
||||
type="search"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
placeholder={t("filters.searchPlaceholder")}
|
||||
className="w-full rounded-lg border border-border bg-bg py-2 pl-9 pr-3 text-sm text-text-main placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-primary/40"
|
||||
data-testid="search-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1" role="group" aria-label={t("filters.category")}>
|
||||
{(["all", "api", "cli"] as FilterCategory[]).map((cat) => (
|
||||
<button
|
||||
key={cat}
|
||||
onClick={() => setFilter(cat)}
|
||||
data-testid={`filter-${cat}`}
|
||||
className={`rounded-lg px-3 py-2 text-xs font-medium transition-colors border ${
|
||||
filter === cat
|
||||
? "bg-primary/10 text-primary border-primary/30"
|
||||
: "bg-bg text-text-muted border-border hover:bg-bg-subtle hover:text-text-main"
|
||||
}`}
|
||||
>
|
||||
{cat === "all"
|
||||
? "Todas"
|
||||
: cat === "api"
|
||||
? t("categoryApi")
|
||||
: t("categoryCli")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Two-column grid: left = skill cards, right = preview */}
|
||||
<div className="grid grid-cols-12 gap-4" data-testid="skills-grid">
|
||||
{/* Left: skill cards list (col-span 7) */}
|
||||
<div
|
||||
className="col-span-12 lg:col-span-7 flex flex-col gap-2"
|
||||
data-testid="skills-list"
|
||||
>
|
||||
{loadingCatalog ? (
|
||||
Array.from({ length: 6 }).map((_, i) => <SkillCardSkeleton key={i} />)
|
||||
) : filteredSkills.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border p-8 text-center">
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">
|
||||
search_off
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">{t("noSkillsFound")}</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredSkills.map((skill) => (
|
||||
<SkillCard
|
||||
key={skill.id}
|
||||
skill={skill}
|
||||
selected={selectedId === skill.id}
|
||||
onClick={() => handleSelectCard(skill.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: preview pane (col-span 5) */}
|
||||
<div className="col-span-12 lg:col-span-5" data-testid="preview-column">
|
||||
<SkillPreviewPane
|
||||
skillId={selectedId}
|
||||
markdown={selectedMarkdown}
|
||||
loading={loadingPreview}
|
||||
onRefresh={selectedId ? handleRefreshPreview : undefined}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgentSkillsPageClient;
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { SkillCoverage } from "@/lib/agentSkills/types";
|
||||
|
||||
interface CoverageBarProps {
|
||||
coverage: SkillCoverage;
|
||||
}
|
||||
|
||||
function barColor(have: number, total: number): string {
|
||||
const pct = total > 0 ? have / total : 0;
|
||||
if (pct >= 1) return "bg-emerald-500";
|
||||
if (pct >= 0.75) return "bg-amber-400";
|
||||
return "bg-red-500";
|
||||
}
|
||||
|
||||
function trackColor(have: number, total: number): string {
|
||||
const pct = total > 0 ? have / total : 0;
|
||||
if (pct >= 1) return "bg-emerald-500/20";
|
||||
if (pct >= 0.75) return "bg-amber-400/20";
|
||||
return "bg-red-500/20";
|
||||
}
|
||||
|
||||
export function CoverageBar({ coverage }: CoverageBarProps): JSX.Element {
|
||||
const t = useTranslations("agentSkills");
|
||||
const { api, cli } = coverage;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 text-xs" data-testid="coverage-bar">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-text-muted shrink-0">
|
||||
{t("categoryApi")} {api.have}/{api.total}
|
||||
</span>
|
||||
<div
|
||||
className={`flex-1 h-2 rounded-full overflow-hidden ${trackColor(api.have, api.total)}`}
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={api.have}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={api.total}
|
||||
aria-label={`${t("categoryApi")} ${api.have}/${api.total}`}
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor(api.have, api.total)}`}
|
||||
style={{ width: `${api.total > 0 ? (api.have / api.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-text-muted w-12 text-right">
|
||||
{api.total > 0 ? Math.round((api.have / api.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium text-text-muted shrink-0">
|
||||
{t("categoryCli")} {cli.have}/{cli.total}
|
||||
</span>
|
||||
<div
|
||||
className={`flex-1 h-2 rounded-full overflow-hidden ${trackColor(cli.have, cli.total)}`}
|
||||
>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-valuenow={cli.have}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={cli.total}
|
||||
aria-label={`${t("categoryCli")} ${cli.have}/${cli.total}`}
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor(cli.have, cli.total)}`}
|
||||
style={{ width: `${cli.total > 0 ? (cli.have / cli.total) * 100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-text-muted w-12 text-right">
|
||||
{cli.total > 0 ? Math.round((cli.have / cli.total) * 100) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CoverageBar;
|
||||
@@ -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 (
|
||||
<div className="flex-1 flex items-start gap-3 rounded-lg border border-border bg-bg-subtle p-3 min-w-0">
|
||||
<div className="flex items-center justify-center size-8 rounded-lg bg-primary/10 shrink-0">
|
||||
<span className="material-symbols-outlined text-primary text-[16px]">{icon}</span>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-1 mb-1">
|
||||
<span className="text-xs font-semibold text-text-main">{label}</span>
|
||||
<button
|
||||
onClick={() => void handleCopy()}
|
||||
className={`flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-medium transition-colors shrink-0 ${
|
||||
copied
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-bg text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
title={t("copyUrl")}
|
||||
aria-label={`${t("copyUrl")} ${label}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[11px]">
|
||||
{copied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{copied ? "✓" : t("copyUrl")}
|
||||
</button>
|
||||
</div>
|
||||
<code className="block truncate text-[10px] font-mono text-text-muted">{url}</code>
|
||||
<p className="mt-1 text-[10px] text-text-muted leading-relaxed italic">{prompt}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col sm:flex-row gap-2" data-testid="mcp-a2a-links-bar">
|
||||
<LinkCard
|
||||
label={t("mcpUrl")}
|
||||
url={mcpUrl}
|
||||
icon="electrical_services"
|
||||
prompt="Add this MCP endpoint to your agent to give it 37 OmniRoute tools."
|
||||
/>
|
||||
<LinkCard
|
||||
label={t("a2aLink")}
|
||||
url={a2aUrl}
|
||||
icon="hub"
|
||||
prompt="Register this Agent Card with your orchestrator to enable A2A task delegation."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default McpA2aLinksBar;
|
||||
@@ -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<HTMLDivElement>) => {
|
||||
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 (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
onClick={onClick}
|
||||
onKeyDown={handleKeyDown}
|
||||
data-testid={`skill-card-${skill.id}`}
|
||||
className={`flex items-start gap-3 rounded-lg border p-3 cursor-pointer transition-colors outline-none focus-visible:ring-2 focus-visible:ring-primary/50 ${
|
||||
selected
|
||||
? "border-primary/40 bg-primary/5"
|
||||
: "border-border bg-bg hover:bg-bg-subtle hover:border-border"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${
|
||||
selected ? "bg-primary/15" : "bg-bg-subtle"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[18px] ${
|
||||
selected ? "text-primary" : "text-text-muted"
|
||||
}`}
|
||||
>
|
||||
{skill.icon ?? "article"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
|
||||
|
||||
<span
|
||||
className={`rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
skill.category === "api"
|
||||
? "bg-blue-500/10 text-blue-700 dark:text-blue-400"
|
||||
: "bg-violet-500/10 text-violet-700 dark:text-violet-400"
|
||||
}`}
|
||||
>
|
||||
{skill.category === "api" ? t("categoryApi") : t("categoryCli")}
|
||||
</span>
|
||||
|
||||
{skill.isEntry && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
start
|
||||
</span>
|
||||
)}
|
||||
|
||||
{skill.isNew && (
|
||||
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
new
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs leading-relaxed text-text-muted line-clamp-2">{skill.description}</p>
|
||||
|
||||
{previewItems.length > 0 && (
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{previewItems.map((item) => (
|
||||
<code
|
||||
key={item}
|
||||
className="rounded bg-bg-subtle px-1.5 py-0.5 font-mono text-[10px] text-text-muted border border-border/50"
|
||||
>
|
||||
{item}
|
||||
</code>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillCard;
|
||||
@@ -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: () => <SkeletonLines lines={6} />,
|
||||
});
|
||||
|
||||
// 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 (
|
||||
<div className="space-y-2 animate-pulse" aria-hidden="true">
|
||||
{Array.from({ length: lines }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-3 rounded bg-bg-subtle"
|
||||
style={{ width: `${60 + ((i * 17) % 40)}%` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="flex flex-col items-center justify-center h-full min-h-[300px] rounded-xl border border-dashed border-border bg-bg-subtle/30 p-8 text-center"
|
||||
data-testid="skill-preview-empty"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[32px] text-text-muted mb-3">
|
||||
article
|
||||
</span>
|
||||
<p className="text-sm text-text-muted">{t("previewEmpty")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col rounded-xl border border-border bg-bg h-full"
|
||||
data-testid="skill-preview-pane"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-border px-4 py-2.5 shrink-0">
|
||||
<span className="text-xs font-mono font-semibold text-text-muted truncate">
|
||||
{skillId}/SKILL.md
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors disabled:opacity-50"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<span
|
||||
className={`material-symbols-outlined text-[14px] ${loading ? "animate-spin" : ""}`}
|
||||
>
|
||||
refresh
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => void handleCopyRawUrl()}
|
||||
disabled={!skillId}
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
title={t("copyUrl")}
|
||||
aria-label={t("copyUrl")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">content_copy</span>
|
||||
</button>
|
||||
{githubUrl && (
|
||||
<a
|
||||
href={githubUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs text-text-muted hover:text-text-main hover:bg-bg-subtle transition-colors"
|
||||
title={t("viewOnGithub")}
|
||||
aria-label={t("viewOnGithub")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-4 min-h-0">
|
||||
{loading ? (
|
||||
<SkeletonLines lines={12} />
|
||||
) : markdown ? (
|
||||
<div
|
||||
className="prose prose-sm dark:prose-invert max-w-none text-text-main"
|
||||
data-testid="skill-preview-markdown"
|
||||
>
|
||||
<ReactMarkdown>{markdown}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/30 p-3 text-xs text-red-700 dark:text-red-400"
|
||||
data-testid="skill-preview-error"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{t("previewError")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillPreviewPane;
|
||||
@@ -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 (
|
||||
<button
|
||||
onClick={() => void copy(url, url)}
|
||||
className={`flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors ${
|
||||
isCopied
|
||||
? "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400"
|
||||
: "bg-bg-subtle text-text-muted hover:text-text-main"
|
||||
}`}
|
||||
title={t("copyRawUrlTitle")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{isCopied ? "check" : "content_copy"}
|
||||
</span>
|
||||
{isCopied ? t("copied") : t("copyUrl")}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({ skill }: { skill: AgentSkill }) {
|
||||
const t = useTranslations("agents");
|
||||
const rawUrl = getAgentSkillRawUrl(skill.id);
|
||||
const blobUrl = getAgentSkillBlobUrl(skill.id);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 rounded-lg border border-border p-3 transition-colors hover:bg-bg-subtle">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-bg-subtle">
|
||||
<span className="material-symbols-outlined text-[18px] text-text-muted">{skill.icon}</span>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="mb-0.5 flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-sm font-semibold text-text-main">{skill.name}</span>
|
||||
{skill.isEntry && (
|
||||
<span className="rounded-full bg-primary/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-primary">
|
||||
{t("startHere")}
|
||||
</span>
|
||||
)}
|
||||
{skill.isNew && (
|
||||
<span className="rounded-full bg-amber-500/10 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide text-amber-700 dark:text-amber-400">
|
||||
{t("badgeNew")}
|
||||
</span>
|
||||
)}
|
||||
{skill.endpoint && (
|
||||
<code className="rounded bg-bg-subtle px-1.5 py-0.5 font-mono text-[10px] text-text-muted">
|
||||
{skill.endpoint}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs leading-relaxed text-text-muted">{skill.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<a
|
||||
href={blobUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1 rounded px-2 py-1 text-xs font-medium text-text-muted transition-colors hover:bg-bg-subtle hover:text-text-main"
|
||||
title={t("viewOnGithub")}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
</a>
|
||||
<CopyButton url={rawUrl} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSection({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
skills,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
icon: string;
|
||||
skills: AgentSkill[];
|
||||
}) {
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border border-border bg-bg p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[20px] text-text-muted">{icon}</span>
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-text-main">{title}</h2>
|
||||
<p className="text-xs text-text-muted">{subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{skills.map((skill) => (
|
||||
<SkillRow key={skill.id} skill={skill} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* How to use — full width */}
|
||||
<div className="rounded-xl border border-border bg-bg-subtle/50 p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary">info</span>
|
||||
<span className="text-sm font-semibold text-text-main">{t("howToUse")}</span>
|
||||
</div>
|
||||
<ol className="space-y-1 text-xs text-text-muted">
|
||||
<li>
|
||||
1.{" "}
|
||||
{t.rich("howToUseStep1", {
|
||||
copyUrl: t("copyUrl"),
|
||||
bold: (chunks) => <strong className="text-text-main">{chunks}</strong>,
|
||||
})}
|
||||
</li>
|
||||
<li>
|
||||
2. {t("howToUseStep2")}
|
||||
<br />
|
||||
<code className="mt-1 block rounded border border-border bg-bg px-2 py-1 font-mono text-[11px]">
|
||||
{t("howToUseStep2Code")}
|
||||
</code>
|
||||
</li>
|
||||
<li>3. {t("howToUseStep3")}</li>
|
||||
</ol>
|
||||
<a
|
||||
href={AGENT_SKILLS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-3 inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">open_in_new</span>
|
||||
{t("browseAllSkillsOnGithub")}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Two-column grid: API Skills | CLI Skills */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkillSection
|
||||
title={t("apiSkills")}
|
||||
subtitle={t("apiSkillsSubtitle", { count: apiSkills.length })}
|
||||
icon="api"
|
||||
skills={apiSkills}
|
||||
/>
|
||||
<SkillSection
|
||||
title={t("cliSkills")}
|
||||
subtitle={t("cliSkillsSubtitle", { count: cliSkills.length })}
|
||||
icon="terminal"
|
||||
skills={cliSkills}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
export default function Page() {
|
||||
return <AgentSkillsPageClient />;
|
||||
}
|
||||
|
||||
631
tests/unit/agent-skills-page.test.tsx
Normal file
631
tests/unit/agent-skills-page.test.tsx
Normal file
@@ -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;
|
||||
}) => (
|
||||
<a href={href} className={className}>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── 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 <div data-testid="react-markdown">{children}</div>;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Fixture helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
function makeSkill(overrides: Partial<AgentSkill> = {}): 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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
// 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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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<typeof vi.fn>).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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<AgentSkillsPageClient />);
|
||||
});
|
||||
|
||||
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(<CoverageBar coverage={FULL_COVERAGE} />);
|
||||
});
|
||||
|
||||
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(<CoverageBar coverage={lowCoverage} />);
|
||||
});
|
||||
|
||||
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(<CoverageBar coverage={partialCoverage} />);
|
||||
});
|
||||
|
||||
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(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
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(<SkillCard skill={makeSkill()} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
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(<SkillCard skill={makeSkill()} selected={true} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
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(<SkillCard skill={makeSkill()} selected={false} onClick={handleClick} />);
|
||||
});
|
||||
|
||||
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(<SkillCard skill={skill} selected={false} onClick={() => {}} />);
|
||||
});
|
||||
|
||||
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(
|
||||
<SkillPreviewPane skillId={null} markdown={null} loading={false} />,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SkillPreviewPane
|
||||
skillId="omni-providers"
|
||||
markdown="# Providers\nContent here."
|
||||
loading={false}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
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(
|
||||
<SkillPreviewPane skillId="omni-providers" markdown="" loading={false} />,
|
||||
);
|
||||
});
|
||||
|
||||
// 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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user