Files
fusion/packages/dashboard/app/__tests__/api-skills.test.ts
gsxdsm 5c4c765134 FN-5904: feat(FN-5904): add browse-and-install flow for skills.sh catalog in Skills view
Add end-to-end browse-and-install flow for skills.sh catalog entries in the dashboard Skills view, enabling users to install skills directly from the catalog UI.

- Add POST /api/skills/install route with source validation and scoped project cwd
- Add installSkill method to SkillsAdapter using supervised npx skills add spawn
- Add installSkill client API function and Install button on catalog cards
- Refresh discovered skills list automatically after successful install
- Handle install errors with structured error codes (invalid_source, install_failed, install_timeout, spawn_error)
- Add card header layout with install button, disabled state, and loading spinner
- Add comprehensive tests: adapter unit tests, route integration tests, component tests
- Add changeset for @runfusion/fusion minor bump
- Update dashboard-guide.md with install endpoint documentation

Files changed:
 .changeset/four-oranges-film.md                    |   5 +
 docs/dashboard-guide.md                            |  45 +++++++
 packages/dashboard/app/__tests__/api-skills.test.ts |  46 +++++++
 packages/dashboard/app/api/legacy.ts               |  12 ++
 packages/dashboard/app/components/SkillsView.css   |  16 +++
 packages/dashboard/app/components/SkillsView.tsx   |  83 +++++++++---
 packages/dashboard/app/components/__tests__/SkillsView.test.tsx |  72 +++++++++++
 packages/dashboard/src/__tests__/routes-skills.test.ts |  67 ++++++++++
 packages/dashboard/src/__tests__/skills-adapter.test.ts | 113 ++++++++++++++++
 packages/dashboard/src/routes/__tests__/register-agent-skills-routes.test.ts | 144 +++++++++++++++++++++
 packages/dashboard/src/routes/register-agent-skills-routes.ts |  51 ++++++++
 packages/dashboard/src/skills-adapter.ts           | 103 +++++++++++++++
 12 files changed, 736 insertions(+), 21 deletions(-)

Fusion-Task-Id: FN-5904

Fusion-Task-Lineage: 338d53e3-baba-4c6c-9173-8e214242403c
2026-06-02 18:29:53 -07:00

319 lines
8.7 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchDiscoveredSkills,
toggleExecutionSkill,
installSkill,
fetchSkillsCatalog,
type DiscoveredSkill,
type CatalogFetchResult,
type ToggleSkillResult,
} from "../api";
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json",
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
describe("fetchDiscoveredSkills", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches discovered skills without projectId", async () => {
const skills: DiscoveredSkill[] = [
{
id: "test-package::skills/test-skill/SKILL.md",
name: "test-skill",
path: "/path/to/skills/test-skill/SKILL.md",
relativePath: "skills/test-skill/SKILL.md",
enabled: true,
metadata: {
source: "test-package",
scope: "project",
origin: "package",
},
},
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { skills }));
const result = await fetchDiscoveredSkills();
expect(result).toEqual(skills);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/discovered",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("fetches discovered skills with projectId", async () => {
const skills: DiscoveredSkill[] = [];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { skills }));
await fetchDiscoveredSkills("proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/discovered?projectId=proj_123",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Failed to discover skills" }, 500),
);
await expect(fetchDiscoveredSkills()).rejects.toThrow("Failed to discover skills");
});
});
describe("toggleExecutionSkill", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("toggles skill without projectId", async () => {
const result: ToggleSkillResult = {
settingsPath: "skills",
pattern: "+test-skill",
targetFile: "/path/to/settings.json",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
const output = await toggleExecutionSkill(
"test-package::skills/test-skill/SKILL.md",
true,
);
expect(output).toEqual(result);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/execution",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
skillId: "test-package::skills/test-skill/SKILL.md",
enabled: true,
}),
}),
);
});
it("toggles skill with projectId", async () => {
const result: ToggleSkillResult = {
settingsPath: "packages[].skills",
pattern: "-test-skill",
targetFile: "/path/to/settings.json",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await toggleExecutionSkill(
"test-package::skills/test-skill/SKILL.md",
false,
"proj_456",
);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/execution?projectId=proj_456",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({
skillId: "test-package::skills/test-skill/SKILL.md",
enabled: false,
}),
}),
);
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Skill not found" }, 404),
);
await expect(
toggleExecutionSkill("invalid-skill", true),
).rejects.toThrow("Skill not found");
});
});
describe("installSkill", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts install requests without projectId", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true }));
const output = await installSkill("owner/repo", "skill-name");
expect(output).toEqual({ success: true });
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/install",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ source: "owner/repo", skill: "skill-name" }),
}),
);
});
it("includes projectId in install requests", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true }));
await installSkill("owner/repo", undefined, "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/install?projectId=proj_123",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ source: "owner/repo", skill: undefined }),
}),
);
});
it("propagates install errors", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "installer failed", code: "install_failed" }, 502),
);
await expect(installSkill("owner/repo", undefined)).rejects.toThrow("installer failed");
});
});
describe("fetchSkillsCatalog", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches catalog with default parameters", async () => {
const result: CatalogFetchResult = {
entries: [
{
id: "skill-1",
slug: "skill-one",
name: "Skill One",
description: "A test skill",
installation: {
installed: false,
matchingSkillIds: [],
matchingPaths: [],
},
},
],
auth: {
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
const output = await fetchSkillsCatalog();
expect(output).toEqual(result);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/catalog",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("fetches catalog with query and limit", async () => {
const result: CatalogFetchResult = {
entries: [],
auth: {
mode: "authenticated",
tokenPresent: true,
fallbackUsed: false,
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await fetchSkillsCatalog("react", 10);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/catalog?q=react&limit=10",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("fetches catalog with projectId", async () => {
const result: CatalogFetchResult = {
entries: [],
auth: {
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await fetchSkillsCatalog(undefined, undefined, "proj_789");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/catalog?projectId=proj_789",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("combines query, limit, and projectId", async () => {
const result: CatalogFetchResult = {
entries: [],
auth: {
mode: "unauthenticated",
tokenPresent: false,
fallbackUsed: false,
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, result));
await fetchSkillsCatalog("typescript", 5, "proj_abc");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/skills/catalog?q=typescript&limit=5&projectId=proj_abc",
expect.objectContaining({
headers: { "Content-Type": "application/json" },
}),
);
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Upstream request timed out" }, 500),
);
await expect(fetchSkillsCatalog()).rejects.toThrow(
"Upstream request timed out",
);
});
});