fix(FN-1987): move skills search to top and improve catalog fallback

- Move the shared skills search input to the top of SkillsView so it applies to discovered skills and catalog results
- Add client-side discovered skills filtering by name/path with a dedicated filtered-empty-state message
- Extend skills adapter fallback logic to use unauthenticated catalog search when authenticated requests return 400, 401, or 403
- Add dashboard component and adapter tests covering discovered filtering behavior and authenticated-endpoint fallback scenarios
This commit is contained in:
Fusion
2026-04-17 17:06:09 -07:00
committed by gsxdsm
parent c6f3b8c074
commit 81d2cbeb56
5 changed files with 314 additions and 17 deletions

View File

@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { createSkillsAdapter } from "../skills-adapter.js";
describe("createSkillsAdapter - fetchCatalog fallback behavior", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.restoreAllMocks();
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.SKILLS_SH_TOKEN;
});
it("falls back to public search endpoint when authenticated endpoint returns 400", async () => {
process.env.SKILLS_SH_TOKEN = "test-token";
let fetchCallCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
fetchCallCount++;
if (urlStr.includes("/api/v1/skills")) {
return Promise.resolve({
ok: false,
status: 400,
statusText: "Bad Request",
json: () => Promise.resolve(null),
});
}
return Promise.resolve({
ok: true,
status: 200,
headers: new Map([["content-type", "application/json"]]),
json: () =>
Promise.resolve({
skills: [{ id: "s1", name: "Found Skill", skillId: "s1" }],
}),
});
}) as unknown as typeof fetch;
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"),
});
const result = await adapter.fetchCatalog({ limit: 20, query: "test" });
expect(fetchCallCount).toBe(2);
expect("entries" in result).toBe(true);
if ("entries" in result) {
expect(result.entries).toHaveLength(1);
expect(result.entries[0]!.name).toBe("Found Skill");
expect(result.auth.fallbackUsed).toBe(true);
}
});
it("falls back to public search endpoint on 401", async () => {
process.env.SKILLS_SH_TOKEN = "test-token";
let fetchCallCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
fetchCallCount++;
if (urlStr.includes("/api/v1/skills")) {
return Promise.resolve({
ok: false,
status: 401,
statusText: "Unauthorized",
json: () => Promise.resolve(null),
});
}
return Promise.resolve({
ok: true,
status: 200,
headers: new Map([["content-type", "application/json"]]),
json: () =>
Promise.resolve({
skills: [{ id: "s2", name: "Fallback Skill", skillId: "s2" }],
}),
});
}) as unknown as typeof fetch;
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"),
});
const result = await adapter.fetchCatalog({ limit: 20, query: "test" });
expect(fetchCallCount).toBe(2);
expect("entries" in result).toBe(true);
if ("entries" in result) {
expect(result.entries).toHaveLength(1);
expect(result.entries[0]!.name).toBe("Fallback Skill");
expect(result.auth.fallbackUsed).toBe(true);
}
});
it("falls back to public search endpoint on 403", async () => {
process.env.SKILLS_SH_TOKEN = "test-token";
let fetchCallCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
fetchCallCount++;
if (urlStr.includes("/api/v1/skills")) {
return Promise.resolve({
ok: false,
status: 403,
statusText: "Forbidden",
json: () => Promise.resolve(null),
});
}
return Promise.resolve({
ok: true,
status: 200,
headers: new Map([["content-type", "application/json"]]),
json: () =>
Promise.resolve({
skills: [{ id: "s3", name: "Forbidden Fallback", skillId: "s3" }],
}),
});
}) as unknown as typeof fetch;
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"),
});
const result = await adapter.fetchCatalog({ limit: 20, query: "test" });
expect(fetchCallCount).toBe(2);
expect("entries" in result).toBe(true);
if ("entries" in result) {
expect(result.entries).toHaveLength(1);
expect(result.entries[0]!.name).toBe("Forbidden Fallback");
expect(result.auth.fallbackUsed).toBe(true);
}
});
it("returns UpstreamError when authenticated endpoint returns 500", async () => {
process.env.SKILLS_SH_TOKEN = "test-token";
let fetchCallCount = 0;
globalThis.fetch = vi.fn().mockImplementation((url: string | URL | Request) => {
const urlStr = typeof url === "string" ? url : url.toString();
fetchCallCount++;
if (urlStr.includes("/api/v1/skills")) {
return Promise.resolve({
ok: false,
status: 500,
statusText: "Internal Server Error",
json: () => Promise.resolve(null),
});
}
// This should NOT be called
return Promise.resolve({
ok: true,
status: 200,
headers: new Map([["content-type", "application/json"]]),
json: () =>
Promise.resolve({
skills: [],
}),
});
}) as unknown as typeof fetch;
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"),
});
const result = await adapter.fetchCatalog({ limit: 20, query: "test" });
expect(fetchCallCount).toBe(1);
expect("error" in result).toBe(true);
if ("error" in result) {
expect(result.code).toBe("upstream_http_error");
expect(result.error).toContain("500");
}
});
it("uses public search endpoint when no token is present", async () => {
// Ensure no token
delete process.env.SKILLS_SH_TOKEN;
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: new Map([["content-type", "application/json"]]),
json: () =>
Promise.resolve({
skills: [{ id: "s4", name: "Public Skill", skillId: "s4" }],
}),
}) as unknown as typeof fetch;
const adapter = createSkillsAdapter({
packageManager: { resolve: vi.fn().mockResolvedValue({ skills: [] }) },
getSettingsPath: vi.fn().mockReturnValue("/tmp/settings.json"),
});
const result = await adapter.fetchCatalog({ limit: 20, query: "test" });
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect("entries" in result).toBe(true);
if ("entries" in result) {
expect(result.entries).toHaveLength(1);
expect(result.entries[0]!.name).toBe("Public Skill");
expect(result.auth.tokenPresent).toBe(false);
expect(result.auth.fallbackUsed).toBe(false);
}
});
});