feat(FN-1792): merge fusion/fn-1792
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map } from "lucide-react";
|
||||
import { Settings, Pause, Play, Square, LayoutGrid, List, Terminal, Lightbulb, Search, X, Activity, MoreHorizontal, Clock, Folder, History, GitBranch, Server, Workflow, Bot, ChevronLeft, Target, ChevronRight, FileCode, Loader2, Grid3X3, Mail, MessageSquare, ChevronDown, Check, Map, Zap } from "lucide-react";
|
||||
import type { ProjectInfo } from "../api";
|
||||
import type { NodeConfig, ProjectStatus } from "@fusion/core";
|
||||
import { fetchScripts } from "../api";
|
||||
@@ -180,8 +180,8 @@ export interface HeaderProps {
|
||||
enginePaused?: boolean;
|
||||
onToggleGlobalPause?: () => void;
|
||||
onToggleEnginePause?: () => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps") => void;
|
||||
view?: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
|
||||
onChangeView?: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
|
||||
searchQuery?: string;
|
||||
onSearchChange?: (query: string) => void;
|
||||
/** Multi-project props */
|
||||
@@ -675,6 +675,15 @@ export function Header({
|
||||
>
|
||||
<MessageSquare size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "skills" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("skills")}
|
||||
title="Skills view"
|
||||
aria-label="Skills view"
|
||||
aria-pressed={view === "skills"}
|
||||
>
|
||||
<Zap size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${view === "roadmaps" ? " active" : ""}`}
|
||||
onClick={() => onChangeView("roadmaps")}
|
||||
|
||||
@@ -21,15 +21,16 @@ import {
|
||||
Terminal,
|
||||
Workflow,
|
||||
Map,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import { fetchScripts } from "../api";
|
||||
import { useViewportMode } from "./Header";
|
||||
|
||||
export interface MobileNavBarProps {
|
||||
/** Current task view mode */
|
||||
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps";
|
||||
view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills";
|
||||
/** Change task view handler */
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps") => void;
|
||||
onChangeView: (view: "board" | "list" | "agents" | "missions" | "chat" | "roadmaps" | "skills") => void;
|
||||
/** Whether the ExecutorStatusBar footer is visible */
|
||||
footerVisible: boolean;
|
||||
/** Whether any full-screen modal is currently open (hides the tab bar) */
|
||||
@@ -228,6 +229,18 @@ export function MobileNavBar({
|
||||
<span className="mobile-nav-tab-label">Chat</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-nav-tab${view === "skills" ? " mobile-nav-tab--active" : ""}`}
|
||||
data-testid="mobile-nav-tab-skills"
|
||||
role="tab"
|
||||
aria-selected={view === "skills"}
|
||||
onClick={() => onChangeView("skills")}
|
||||
>
|
||||
<Zap />
|
||||
<span className="mobile-nav-tab-label">Skills</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className={`mobile-nav-tab${view === "roadmaps" ? " mobile-nav-tab--active" : ""}`}
|
||||
|
||||
270
packages/dashboard/app/components/SkillsView.tsx
Normal file
270
packages/dashboard/app/components/SkillsView.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Wrench, RefreshCw, X } from "lucide-react";
|
||||
import {
|
||||
fetchDiscoveredSkills,
|
||||
toggleExecutionSkill,
|
||||
fetchSkillsCatalog,
|
||||
} from "../api";
|
||||
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult } from "@fusion/dashboard";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
|
||||
interface SkillsViewProps {
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export interface DiscoveredSkillDisplay extends DiscoveredSkill {
|
||||
toggling?: boolean;
|
||||
}
|
||||
|
||||
export function SkillsView({ projectId, addToast, onClose }: SkillsViewProps) {
|
||||
const [discoveredSkills, setDiscoveredSkills] = useState<DiscoveredSkillDisplay[]>([]);
|
||||
const [isLoadingDiscovered, setIsLoadingDiscovered] = useState(true);
|
||||
const [isLoadingCatalog, setIsLoadingCatalog] = useState(false);
|
||||
const [catalogError, setCatalogError] = useState<string | null>(null);
|
||||
const [catalogEntries, setCatalogEntries] = useState<CatalogEntry[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Debounce timer for catalog search
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [debouncedQuery, setDebouncedQuery] = useState("");
|
||||
|
||||
// Fetch discovered skills
|
||||
const loadDiscoveredSkills = useCallback(async () => {
|
||||
setIsLoadingDiscovered(true);
|
||||
try {
|
||||
const skills = await fetchDiscoveredSkills(projectId);
|
||||
setDiscoveredSkills(skills);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Failed to load discovered skills";
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
setIsLoadingDiscovered(false);
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
|
||||
// Fetch catalog
|
||||
const loadCatalog = useCallback(async (query: string) => {
|
||||
setIsLoadingCatalog(true);
|
||||
setCatalogError(null);
|
||||
try {
|
||||
const result = await fetchSkillsCatalog(query, 20, projectId);
|
||||
setCatalogEntries(result.entries);
|
||||
} catch (err) {
|
||||
// Check for upstream error with code (502 etc.)
|
||||
const error = err as { error?: string; code?: string };
|
||||
if (error.error && error.code) {
|
||||
setCatalogError("Catalog is temporarily unavailable. Please try again later.");
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : "Failed to load catalog";
|
||||
setCatalogError(message);
|
||||
}
|
||||
} finally {
|
||||
setIsLoadingCatalog(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
void loadDiscoveredSkills();
|
||||
void loadCatalog("");
|
||||
}, [loadDiscoveredSkills, loadCatalog]);
|
||||
|
||||
// Handle search input with debounce
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchQuery(value);
|
||||
|
||||
if (debounceRef.current) {
|
||||
clearTimeout(debounceRef.current);
|
||||
}
|
||||
|
||||
debounceRef.current = setTimeout(() => {
|
||||
setDebouncedQuery(value);
|
||||
}, 300);
|
||||
}, []);
|
||||
|
||||
// Fetch catalog when debounced query changes
|
||||
useEffect(() => {
|
||||
void loadCatalog(debouncedQuery);
|
||||
}, [debouncedQuery, loadCatalog]);
|
||||
|
||||
// Handle toggle skill
|
||||
const handleToggleSkill = useCallback(async (skillId: string, currentEnabled: boolean) => {
|
||||
const newEnabled = !currentEnabled;
|
||||
|
||||
// Optimistic update
|
||||
setDiscoveredSkills((prev) =>
|
||||
prev.map((s) => (s.id === skillId ? { ...s, toggling: true } : s))
|
||||
);
|
||||
|
||||
try {
|
||||
await toggleExecutionSkill(skillId, newEnabled, projectId);
|
||||
|
||||
// Update local state with new enabled value
|
||||
setDiscoveredSkills((prev) =>
|
||||
prev.map((s) => (s.id === skillId ? { ...s, enabled: newEnabled, toggling: false } : s))
|
||||
);
|
||||
|
||||
addToast(`Skill ${newEnabled ? "enabled" : "disabled"}`, "success");
|
||||
} catch (err) {
|
||||
// Revert optimistic update
|
||||
setDiscoveredSkills((prev) =>
|
||||
prev.map((s) => (s.id === skillId ? { ...s, toggling: false } : s))
|
||||
);
|
||||
|
||||
const message = err instanceof Error ? err.message : "Failed to toggle skill";
|
||||
addToast(`Failed to toggle skill: ${message}`, "error");
|
||||
}
|
||||
}, [projectId, addToast]);
|
||||
|
||||
// Stats
|
||||
const stats = useMemo(() => ({
|
||||
discovered: discoveredSkills.length,
|
||||
enabled: discoveredSkills.filter((s) => s.enabled).length,
|
||||
catalogEntries: catalogEntries.length,
|
||||
}), [discoveredSkills, catalogEntries]);
|
||||
|
||||
return (
|
||||
<div className="skills-view" data-testid="skills-view">
|
||||
{/* Header */}
|
||||
<div className="skills-view-header">
|
||||
<div className="skills-view-title">
|
||||
<h2>
|
||||
<Wrench size={20} />
|
||||
Skills
|
||||
</h2>
|
||||
<span className="skills-view-count">{discoveredSkills.length} discovered</span>
|
||||
</div>
|
||||
|
||||
<div className="skills-view-actions">
|
||||
<button
|
||||
className="btn-icon skills-view-close"
|
||||
onClick={onClose}
|
||||
aria-label="Close skills view"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void loadDiscoveredSkills()}
|
||||
disabled={isLoadingDiscovered}
|
||||
>
|
||||
<RefreshCw size={14} className={isLoadingDiscovered ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discovered Skills Section */}
|
||||
<section className="skills-view-section" aria-labelledby="discovered-skills-title">
|
||||
<h3 id="discovered-skills-title" className="skills-view-section-title">
|
||||
Discovered Skills
|
||||
</h3>
|
||||
|
||||
{isLoadingDiscovered ? (
|
||||
<div className="skills-view-loading">
|
||||
<span className="spinner" />
|
||||
Loading discovered skills...
|
||||
</div>
|
||||
) : discoveredSkills.length === 0 ? (
|
||||
<div className="skills-view-empty">
|
||||
<p>No skills discovered in this project.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="skills-view-list">
|
||||
{discoveredSkills.map((skill) => (
|
||||
<div key={skill.id} className="skills-view-item">
|
||||
<div className="skills-view-item-info">
|
||||
<span className="skills-view-item-name">{skill.name}</span>
|
||||
<span className="skills-view-item-path">{skill.relativePath}</span>
|
||||
<span className="skills-view-item-source">{skill.metadata.source}</span>
|
||||
</div>
|
||||
<label className="skills-view-item-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skill.enabled}
|
||||
disabled={skill.toggling}
|
||||
onChange={() => void handleToggleSkill(skill.id, skill.enabled)}
|
||||
aria-label={`${skill.enabled ? "Disable" : "Enable"} ${skill.name}`}
|
||||
/>
|
||||
<span className="skills-view-toggle-slider" />
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Catalog Section */}
|
||||
<section className="skills-view-section" aria-labelledby="catalog-title">
|
||||
<h3 id="catalog-title" className="skills-view-section-title">
|
||||
Skills Catalog
|
||||
</h3>
|
||||
|
||||
{/* Search */}
|
||||
<div className="skills-view-search">
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Search skills..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => handleSearchChange(e.target.value)}
|
||||
aria-label="Search skills catalog"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Catalog Content */}
|
||||
{catalogError ? (
|
||||
<div className="skills-view-error">
|
||||
<p>{catalogError}</p>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => void loadCatalog(debouncedQuery)}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
) : isLoadingCatalog ? (
|
||||
<div className="skills-view-loading">
|
||||
<span className="spinner" />
|
||||
Loading catalog...
|
||||
</div>
|
||||
) : catalogEntries.length === 0 ? (
|
||||
<div className="skills-view-empty">
|
||||
{searchQuery ? (
|
||||
<p>No skills match your search.</p>
|
||||
) : (
|
||||
<p>No skills available in the catalog.</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="skills-view-grid">
|
||||
{catalogEntries.map((entry) => (
|
||||
<div key={entry.id} className="skills-view-card">
|
||||
<h4 className="skills-view-card-title">{entry.name}</h4>
|
||||
{entry.description && (
|
||||
<p className="skills-view-card-description">{entry.description}</p>
|
||||
)}
|
||||
{entry.tags && entry.tags.length > 0 && (
|
||||
<div className="skills-view-card-tags">
|
||||
{entry.tags.map((tag) => (
|
||||
<span key={tag} className="badge badge--sm">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entry.installs !== undefined && (
|
||||
<span className="skills-view-card-installs">
|
||||
{entry.installs.toLocaleString()} installs
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -328,6 +328,39 @@ describe("Header", () => {
|
||||
expect(missionsBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Skills View Toggle ─────────────────────────────────────────
|
||||
|
||||
it("renders skills view button in view toggle when onChangeView is provided", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const skillsBtn = screen.getByTitle("Skills view");
|
||||
expect(skillsBtn).toBeDefined();
|
||||
});
|
||||
|
||||
it("calls onChangeView with 'skills' when skills view button is clicked", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const skillsBtn = screen.getByTitle("Skills view");
|
||||
fireEvent.click(skillsBtn);
|
||||
expect(onChangeView).toHaveBeenCalledWith("skills");
|
||||
});
|
||||
|
||||
it("marks skills view button as active when view is 'skills'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="skills" onChangeView={onChangeView} />);
|
||||
const skillsBtn = screen.getByTitle("Skills view");
|
||||
expect(skillsBtn.className).toContain("active");
|
||||
expect(skillsBtn.getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not mark skills view button as active when view is 'board'", () => {
|
||||
const onChangeView = vi.fn();
|
||||
render(<Header view="board" onChangeView={onChangeView} />);
|
||||
const skillsBtn = screen.getByTitle("Skills view");
|
||||
expect(skillsBtn.className).not.toContain("active");
|
||||
expect(skillsBtn.getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
// ── Roadmaps View Toggle ───────────────────────────────────────
|
||||
|
||||
it("renders roadmaps view button in view toggle when onChangeView is provided", () => {
|
||||
@@ -497,6 +530,20 @@ describe("Header", () => {
|
||||
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides search input and toggle when view is 'skills'", () => {
|
||||
const onSearchChange = vi.fn();
|
||||
render(
|
||||
<Header
|
||||
view="skills"
|
||||
onChangeView={vi.fn()}
|
||||
searchQuery=""
|
||||
onSearchChange={onSearchChange}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByPlaceholderText("Search tasks...")).toBeNull();
|
||||
expect(screen.queryByTestId("desktop-header-search-btn")).toBeNull();
|
||||
});
|
||||
|
||||
// ── Terminal Button ─────────────────────────────────────────────
|
||||
|
||||
it("renders terminal button with correct title", () => {
|
||||
|
||||
@@ -56,13 +56,16 @@ describe("MobileNavBar", () => {
|
||||
mockViewport("mobile");
|
||||
});
|
||||
|
||||
it("renders five tab buttons (board + list + agents + missions + more)", () => {
|
||||
it("renders eight tab buttons (board + list + agents + missions + chat + skills + roadmaps + more)", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} />);
|
||||
|
||||
expect(screen.getByTestId("mobile-nav-tab-board")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-list")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-agents")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-missions")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-chat")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-roadmaps")).toBeDefined();
|
||||
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -124,6 +127,24 @@ describe("MobileNavBar", () => {
|
||||
expect(screen.getByTestId("mobile-nav-tab-missions").className).not.toContain("mobile-nav-tab--active");
|
||||
});
|
||||
|
||||
it("skills tab calls onChangeView with 'skills'", () => {
|
||||
const props = createDefaultProps();
|
||||
render(<MobileNavBar {...props} view="board" />);
|
||||
|
||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-skills"));
|
||||
expect(props.onChangeView).toHaveBeenCalledWith("skills");
|
||||
});
|
||||
|
||||
it("skills tab is active when view is 'skills'", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} view="skills" />);
|
||||
expect(screen.getByTestId("mobile-nav-tab-skills").className).toContain("mobile-nav-tab--active");
|
||||
});
|
||||
|
||||
it("skills tab is not active when view is 'board'", () => {
|
||||
render(<MobileNavBar {...createDefaultProps()} view="board" />);
|
||||
expect(screen.getByTestId("mobile-nav-tab-skills").className).not.toContain("mobile-nav-tab--active");
|
||||
});
|
||||
|
||||
it("opens and toggles the more sheet", () => {
|
||||
const props = createDefaultProps();
|
||||
const { container } = render(<MobileNavBar {...props} />);
|
||||
|
||||
433
packages/dashboard/app/components/__tests__/SkillsView.test.tsx
Normal file
433
packages/dashboard/app/components/__tests__/SkillsView.test.tsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { SkillsView } from "../SkillsView";
|
||||
import * as apiModule from "../../api";
|
||||
import type { DiscoveredSkill, CatalogEntry } from "@fusion/dashboard";
|
||||
|
||||
// Mock the API module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchDiscoveredSkills: vi.fn(),
|
||||
toggleExecutionSkill: vi.fn(),
|
||||
fetchSkillsCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
|
||||
const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill);
|
||||
const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog);
|
||||
|
||||
describe("SkillsView", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
const projectId = "proj_123";
|
||||
const onClose = vi.fn();
|
||||
|
||||
const mockDiscoveredSkills: DiscoveredSkill[] = [
|
||||
{
|
||||
id: "npm::skills/test-skill",
|
||||
name: "test-skill",
|
||||
path: "/project/.fusion/skills/test-skill",
|
||||
relativePath: "skills/test-skill",
|
||||
enabled: true,
|
||||
metadata: {
|
||||
source: "npm",
|
||||
scope: "project",
|
||||
origin: "top-level",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "github::skills/another-skill",
|
||||
name: "another-skill",
|
||||
path: "/project/.fusion/skills/another-skill",
|
||||
relativePath: "skills/another-skill",
|
||||
enabled: false,
|
||||
metadata: {
|
||||
source: "github",
|
||||
scope: "project",
|
||||
origin: "package",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const mockCatalogEntries: CatalogEntry[] = [
|
||||
{
|
||||
id: "cat-001",
|
||||
slug: "test-skill",
|
||||
name: "Test Skill",
|
||||
description: "A test skill for testing",
|
||||
tags: ["testing", "example"],
|
||||
installs: 1234,
|
||||
installation: {
|
||||
installed: false,
|
||||
matchingSkillIds: [],
|
||||
matchingPaths: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "cat-002",
|
||||
slug: "another-skill",
|
||||
name: "Another Skill",
|
||||
description: "Another example skill",
|
||||
tags: ["utility"],
|
||||
installs: 5678,
|
||||
installation: {
|
||||
installed: true,
|
||||
matchingSkillIds: ["npm::skills/another-skill"],
|
||||
matchingPaths: ["skills/another-skill"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockFetchDiscoveredSkills.mockResolvedValue(mockDiscoveredSkills);
|
||||
mockToggleExecutionSkill.mockResolvedValue({
|
||||
settingsPath: "skills",
|
||||
pattern: "+test-skill",
|
||||
targetFile: "/project/.fusion/settings.json",
|
||||
});
|
||||
mockFetchSkillsCatalog.mockResolvedValue({
|
||||
entries: mockCatalogEntries,
|
||||
auth: {
|
||||
mode: "unauthenticated",
|
||||
tokenPresent: false,
|
||||
fallbackUsed: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering", () => {
|
||||
it("renders the skills view with header", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("skills-view")).toBeTruthy();
|
||||
expect(screen.getByText("Skills")).toBeTruthy();
|
||||
expect(screen.getByText(/discovered/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders both sections after data loads", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Discovered Skills")).toBeTruthy();
|
||||
expect(screen.getByText("Skills Catalog")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("displays discovered skill count in header", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/2 discovered/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders discovered skills list", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-skill")).toBeTruthy();
|
||||
expect(screen.getByText("another-skill")).toBeTruthy();
|
||||
expect(screen.getByText("skills/test-skill")).toBeTruthy();
|
||||
expect(screen.getByText("skills/another-skill")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders source metadata for discovered skills", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const npmSources = screen.getAllByText("npm");
|
||||
expect(npmSources.length).toBe(1);
|
||||
const githubSources = screen.getAllByText("github");
|
||||
expect(githubSources.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("renders catalog entries as cards", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Test Skill")).toBeTruthy();
|
||||
expect(screen.getByText("Another Skill")).toBeTruthy();
|
||||
expect(screen.getByText("A test skill for testing")).toBeTruthy();
|
||||
expect(screen.getByText("Another example skill")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders catalog tags as badges", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("testing")).toBeTruthy();
|
||||
expect(screen.getByText("example")).toBeTruthy();
|
||||
expect(screen.getByText("utility")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders install counts", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/1,234 installs/)).toBeTruthy();
|
||||
expect(screen.getByText(/5,678 installs/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows loading state while fetching discovered skills", async () => {
|
||||
let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined;
|
||||
mockFetchDiscoveredSkills.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveSkills = resolve as unknown as (value: DiscoveredSkill[]) => void; })
|
||||
);
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
expect(screen.getByText("Loading discovered skills...")).toBeTruthy();
|
||||
|
||||
// Complete the fetch
|
||||
await act(async () => {
|
||||
resolveSkills!(mockDiscoveredSkills);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no discovered skills", async () => {
|
||||
mockFetchDiscoveredSkills.mockResolvedValue([]);
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No skills discovered in this project.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows empty state when no catalog entries", async () => {
|
||||
mockFetchSkillsCatalog.mockResolvedValue({
|
||||
entries: [],
|
||||
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
|
||||
});
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("No skills available in the catalog.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggle skill", () => {
|
||||
it("calls toggleExecutionSkill when toggle is clicked", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and click the toggle for test-skill (enabled skill)
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
|
||||
expect(enabledToggle).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledToggle);
|
||||
});
|
||||
|
||||
expect(mockToggleExecutionSkill).toHaveBeenCalledWith(
|
||||
"npm::skills/test-skill",
|
||||
false,
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("updates checked state on successful toggle", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Find and click the toggle for test-skill (enabled skill)
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledToggle);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect((enabledToggle as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows success toast on successful toggle", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledToggle);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
"Skill disabled",
|
||||
"success"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("reverts toggle and shows error toast on failed toggle", async () => {
|
||||
mockToggleExecutionSkill.mockRejectedValue(new Error("Toggle failed"));
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("test-skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggles = screen.getAllByRole("checkbox");
|
||||
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(enabledToggle);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to toggle skill"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
|
||||
// Should revert to original state
|
||||
expect((enabledToggle as HTMLInputElement).checked).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog search", () => {
|
||||
it("calls fetchSkillsCatalog with projectId when provided", async () => {
|
||||
render(<SkillsView projectId={projectId} addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchDiscoveredSkills).toHaveBeenCalledWith(projectId);
|
||||
});
|
||||
|
||||
expect(mockFetchSkillsCatalog).toHaveBeenCalledWith("", 20, projectId);
|
||||
});
|
||||
|
||||
it("shows search input and updates on change", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText("Search skills...")).toBeTruthy();
|
||||
});
|
||||
|
||||
const searchInput = screen.getByPlaceholderText("Search skills...");
|
||||
fireEvent.change(searchInput, { target: { value: "test" } });
|
||||
|
||||
expect((searchInput as HTMLInputElement).value).toBe("test");
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("shows error message for catalog fetch with upstream error", async () => {
|
||||
mockFetchSkillsCatalog.mockRejectedValue({
|
||||
error: "Service unavailable",
|
||||
code: "upstream_http_error",
|
||||
});
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Catalog is temporarily unavailable. Please try again later.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows error toast when fetchDiscoveredSkills fails", async () => {
|
||||
mockFetchDiscoveredSkills.mockRejectedValue(new Error("Failed to load"));
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddToast).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Failed to load"),
|
||||
"error"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Try Again button for catalog error", async () => {
|
||||
mockFetchSkillsCatalog.mockRejectedValue({
|
||||
error: "Service unavailable",
|
||||
code: "upstream_http_error",
|
||||
});
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Try Again")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("refresh functionality", () => {
|
||||
it("refreshes discovered skills when refresh button is clicked", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Refresh")).toBeTruthy();
|
||||
});
|
||||
|
||||
mockFetchDiscoveredSkills.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Refresh"));
|
||||
});
|
||||
|
||||
expect(mockFetchDiscoveredSkills).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refresh button is disabled while loading", async () => {
|
||||
let resolveSkills: ((value: DiscoveredSkill[]) => void) | undefined;
|
||||
mockFetchDiscoveredSkills.mockImplementation(
|
||||
() => new Promise((resolve) => { resolveSkills = resolve as unknown as (value: DiscoveredSkill[]) => void; })
|
||||
);
|
||||
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
// Trigger refresh
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByText("Refresh"));
|
||||
});
|
||||
|
||||
// Button should be disabled during loading
|
||||
expect(screen.getByText("Refresh").closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||
expect(document.querySelector(".spin")).toBeTruthy();
|
||||
|
||||
// Complete the fetch
|
||||
await act(async () => {
|
||||
resolveSkills!(mockDiscoveredSkills);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("close button", () => {
|
||||
it("calls onClose when close button is clicked", async () => {
|
||||
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("Close skills view")).toBeTruthy();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByLabelText("Close skills view"));
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user