Files
fusion/packages/dashboard/app/components/__tests__/SkillsView.test.tsx
Fusion 17339046e1 fix(FN-2510): harden skills catalog fallback for short and invalid queries
- Update skills adapter fallback logic to handle empty and short catalog queries without surfacing 400 failures
- Handle ApiRequestError paths in SkillsView so invalid-query responses degrade gracefully in the UI
- Add regression coverage for adapter, routes, and SkillsView behavior across empty/short query cases
- Document short-query catalog behavior in the dashboard guide and include a patch changeset for @runfusion/fusion
2026-04-25 12:40:32 -07:00

980 lines
31 KiB
TypeScript

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, SkillContent } from "@fusion/dashboard";
// Mock the API module
vi.mock("../../api", () => ({
fetchDiscoveredSkills: vi.fn(),
toggleExecutionSkill: vi.fn(),
fetchSkillsCatalog: vi.fn(),
fetchSkillContent: vi.fn(),
}));
const mockFetchDiscoveredSkills = vi.mocked(apiModule.fetchDiscoveredSkills);
const mockToggleExecutionSkill = vi.mocked(apiModule.toggleExecutionSkill);
const mockFetchSkillsCatalog = vi.mocked(apiModule.fetchSkillsCatalog);
const mockFetchSkillContent = vi.mocked(apiModule.fetchSkillContent);
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 friendly error message for catalog fetch with upstream ApiRequestError", async () => {
mockFetchSkillsCatalog.mockRejectedValue(
Object.assign(new Error("Upstream returned 400: Bad Request"), {
status: 502,
details: { 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(
Object.assign(new Error("Upstream returned 400: Bad Request"), {
status: 502,
details: { 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();
});
});
describe("CSS class assertions", () => {
it("renders with .skills-view root class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelector(".skills-view")).toBeTruthy();
});
});
it("renders .skills-view-header and .skills-view-content sections", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelector(".skills-view-header")).toBeTruthy();
expect(document.querySelector(".skills-view-section")).toBeTruthy();
});
});
it("renders discovered skills list with .skills-view-item rows", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
const items = document.querySelectorAll(".skills-view-item");
expect(items.length).toBeGreaterThanOrEqual(2);
});
});
it("renders catalog cards with .skills-view-card class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelectorAll(".skills-view-card").length).toBeGreaterThanOrEqual(2);
});
});
it("renders search input with .skills-view-search class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelector(".skills-view-search")).toBeTruthy();
});
});
it("renders toggle sliders with .skills-view-toggle-slider class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
const sliders = document.querySelectorAll(".skills-view-toggle-slider");
expect(sliders.length).toBeGreaterThanOrEqual(2);
});
});
it("renders catalog grid with .skills-view-grid class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(document.querySelector(".skills-view-grid")).toBeTruthy();
});
});
});
describe("catalog search debounce", () => {
it("debounces catalog search input", async () => {
vi.useFakeTimers();
vi.advanceTimersByTime(0); // Initialize fake timers
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
// Wait for initial render and API calls
await act(async () => {
vi.runAllTimers();
});
await act(async () => {
vi.advanceTimersByTime(100);
});
mockFetchSkillsCatalog.mockClear();
const searchInput = screen.getByPlaceholderText("Search skills...");
// Type in the search input
fireEvent.change(searchInput, { target: { value: "test" } });
// Should NOT have called fetchSkillsCatalog yet (debounce not triggered)
expect(mockFetchSkillsCatalog).not.toHaveBeenCalled();
// Advance timers by 300ms (component debounce is ~300ms)
await act(async () => {
vi.advanceTimersByTime(300);
});
// Now fetchSkillsCatalog should have been called with the search query
expect(mockFetchSkillsCatalog).toHaveBeenCalledWith("test", 20, undefined);
vi.useRealTimers();
});
it("debounces with original query when clearing search", async () => {
vi.useFakeTimers();
vi.advanceTimersByTime(0); // Initialize fake timers
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
// Wait for initial render and API calls
await act(async () => {
vi.runAllTimers();
});
await act(async () => {
vi.advanceTimersByTime(100);
});
const searchInput = screen.getByPlaceholderText("Search skills...");
// Type something and wait for debounce
fireEvent.change(searchInput, { target: { value: "test" } });
await act(async () => {
vi.advanceTimersByTime(300);
});
mockFetchSkillsCatalog.mockClear();
// Clear the search
fireEvent.change(searchInput, { target: { value: "" } });
await act(async () => {
vi.advanceTimersByTime(300);
});
// Should call with empty query
expect(mockFetchSkillsCatalog).toHaveBeenCalledWith("", 20, undefined);
vi.useRealTimers();
});
});
describe("error-state retry", () => {
it("retry button is displayed when catalog fetch fails", async () => {
mockFetchSkillsCatalog.mockRejectedValue(
Object.assign(new Error("Upstream returned 400: Bad Request"), {
status: 502,
details: { code: "upstream_http_error" },
})
);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Try Again")).toBeTruthy();
});
});
it("error message is displayed when catalog fetch fails", async () => {
mockFetchSkillsCatalog.mockRejectedValue(new Error("Network error"));
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText(/unavailable|error|failed/i)).toBeTruthy();
});
});
it("success state after retry clears error", async () => {
// Use a custom mock function that rejects on first call and resolves on second
let callCount = 0;
mockFetchSkillsCatalog.mockImplementation(() => {
callCount++;
if (callCount === 1) {
return Promise.reject(new Error("Service unavailable"));
}
return Promise.resolve({
entries: mockCatalogEntries,
auth: { mode: "unauthenticated", tokenPresent: false, fallbackUsed: false },
});
});
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("Try Again")).toBeTruthy();
});
// Click retry
await act(async () => {
fireEvent.click(screen.getByText("Try Again"));
});
await waitFor(() => {
// Catalog should now show entries
expect(screen.getByText("Test Skill")).toBeTruthy();
});
});
});
describe("discovered skills filtering", () => {
it("filters discovered skills by search query", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
expect(screen.getByText("another-skill")).toBeTruthy();
});
const searchInput = screen.getByPlaceholderText("Search skills...");
fireEvent.change(searchInput, { target: { value: "test-skill" } });
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
expect(screen.queryByText("another-skill")).toBeNull();
});
});
it("shows filtered empty state when no discovered skills match search", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const searchInput = screen.getByPlaceholderText("Search skills...");
fireEvent.change(searchInput, { target: { value: "zzz-nonexistent" } });
await waitFor(() => {
expect(screen.getByText("No discovered skills match your search.")).toBeTruthy();
});
});
it("shows original empty state when no skills are discovered", async () => {
mockFetchDiscoveredSkills.mockResolvedValue([]);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("No skills discovered in this project.")).toBeTruthy();
});
// Search should not override the "no skills discovered" empty state
const searchInput = screen.getByPlaceholderText("Search skills...");
fireEvent.change(searchInput, { target: { value: "test" } });
await waitFor(() => {
expect(screen.getByText("No skills discovered in this project.")).toBeTruthy();
});
});
it("filters discovered skills by relativePath", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
expect(screen.getByText("another-skill")).toBeTruthy();
});
// Search by path instead of name
const searchInput = screen.getByPlaceholderText("Search skills...");
fireEvent.change(searchInput, { target: { value: "another-skill" } });
await waitFor(() => {
expect(screen.queryByText("test-skill")).toBeNull();
expect(screen.getByText("another-skill")).toBeTruthy();
});
});
});
describe("skill content viewing", () => {
const mockSkillContent: SkillContent = {
name: "test-skill",
skillMd: "# Test Skill\n\nThis is the skill content.",
files: [
{ name: "references", relativePath: "references", type: "directory" },
{ name: "workflows", relativePath: "workflows", type: "directory" },
],
};
beforeEach(() => {
mockFetchSkillContent.mockResolvedValue(mockSkillContent);
});
it("calls fetchSkillContent when clicking a discovered skill item", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
expect(testSkillItem).toBeTruthy();
await act(async () => {
fireEvent.click(testSkillItem!);
});
expect(mockFetchSkillContent).toHaveBeenCalledWith("npm::skills/test-skill", undefined);
});
it("displays skill content when loaded", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
expect(preElement!.textContent).toContain("This is the skill content.");
const fileBadges = document.querySelectorAll(".skills-view-detail-files .badge");
expect(fileBadges.length).toBe(2);
});
});
it("collapses detail when clicking the same skill again", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item to expand
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByTestId("skill-detail")).toBeTruthy();
});
// Click again to collapse
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.queryByTestId("skill-detail")).toBeNull();
});
});
it("does NOT trigger content fetch when clicking toggle", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Find the toggle for test-skill
const toggles = screen.getAllByRole("checkbox");
const enabledToggle = toggles.find(t => (t as HTMLInputElement).checked) as HTMLInputElement;
await act(async () => {
fireEvent.click(enabledToggle);
});
// Should NOT have called fetchSkillContent
expect(mockFetchSkillContent).not.toHaveBeenCalled();
});
it("shows loading state while fetching content", async () => {
let resolveContent: ((value: SkillContent) => void) | undefined;
mockFetchSkillContent.mockImplementation(
() => new Promise((resolve) => { resolveContent = resolve as unknown as (value: SkillContent) => void; })
);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByText("Loading skill content...")).toBeTruthy();
});
// Complete the fetch
await act(async () => {
resolveContent!(mockSkillContent);
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
});
});
it("shows error state on fetch failure", async () => {
mockFetchSkillContent.mockRejectedValue(new Error("Failed to load content"));
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByText("Failed to load content")).toBeTruthy();
expect(screen.getByText("Retry")).toBeTruthy();
});
});
it("retries skill content fetch when retry button is clicked", async () => {
mockFetchSkillContent
.mockRejectedValueOnce(new Error("Failed to load content"))
.mockResolvedValueOnce(mockSkillContent);
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByText("Retry")).toBeTruthy();
});
await act(async () => {
fireEvent.click(screen.getByText("Retry"));
});
await waitFor(() => {
const preElement = document.querySelector(".skills-view-detail-content");
expect(preElement).toBeTruthy();
expect(preElement!.textContent).toContain("# Test Skill");
});
expect(mockFetchSkillContent).toHaveBeenCalledTimes(2);
});
it("collapses detail when close button is clicked", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item to expand
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByTestId("skill-detail")).toBeTruthy();
});
// Click close button
await act(async () => {
fireEvent.click(screen.getByText("Close"));
});
await waitFor(() => {
expect(screen.queryByTestId("skill-detail")).toBeNull();
});
});
it("selected skill item has --selected class", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
// Click on the test-skill item
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
const selectedItem = document.querySelector(".skills-view-item--selected");
expect(selectedItem).toBeTruthy();
expect(selectedItem?.textContent).toContain("test-skill");
});
});
it("renders file badges for supplementary files", async () => {
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
const fileBadges = document.querySelectorAll(".skills-view-detail-files .badge");
expect(fileBadges.length).toBe(2);
});
});
it("shows SKILL.md fallback text when skill content is empty", async () => {
mockFetchSkillContent.mockResolvedValue({
name: "empty-skill",
skillMd: "",
files: [],
});
render(<SkillsView addToast={mockAddToast} onClose={onClose} />);
await waitFor(() => {
expect(screen.getByText("test-skill")).toBeTruthy();
});
const testSkillItem = screen.getByText("test-skill").closest(".skills-view-item");
await act(async () => {
fireEvent.click(testSkillItem!);
});
await waitFor(() => {
expect(screen.getByText("(No SKILL.md found)")).toBeTruthy();
});
});
});
});