Files
cc-switch/tests/hooks/useImportSkillsFromApps.test.tsx
SaladDay 84e75ad24a feat(pi): add native coding agent support (#6064)
* feat(pi): add native coding agent support

* refactor(pi): align native provider management

* fix(pi): allow removing global default provider

* feat(pi): refine provider creation experience

* refactor(pi): align prompt management UI

* feat(pi): refine native prompt and provider UX

* fix(pi): size config preview to content

* feat(pi): refine native provider management

* refactor(pi): remove unused integration paths

* fix(pi): address review feedback

* test(pi): align model picker assertions

* fix(pi): preserve AGENTS.md during restore sync

---------

Co-authored-by: Jason <farion1231@gmail.com>
2026-08-14 17:57:45 +08:00

345 lines
12 KiB
TypeScript

import type { PropsWithChildren } from "react";
import { act, renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { mergeImportedSkills } from "@/hooks/useSkills.helpers";
import {
useImportSkillsFromApps,
useInstallSkill,
useInstallSkillsFromZip,
useUninstallSkill,
} from "@/hooks/useSkills";
import type { DiscoverableSkill, InstalledSkill } from "@/lib/api/skills";
const apiMocks = vi.hoisted(() => ({
importFromApps: vi.fn(),
installFromZip: vi.fn(),
installUnified: vi.fn(),
uninstallUnified: vi.fn(),
}));
vi.mock("@/lib/api/skills", () => ({
skillsApi: apiMocks,
}));
function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
return {
id: "skill-a",
name: "Skill A",
directory: "skill-a",
apps: {
claude: true,
codex: false,
gemini: false,
opencode: false,
openclaw: false,
hermes: false,
pi: false,
},
installedAt: 0,
updatedAt: 0,
...overrides,
};
}
function makeDiscoverableSkill(): DiscoverableSkill {
return {
key: "owner/repo:skill-a",
name: "Skill A",
description: "Skill A description",
directory: "skill-a",
repoOwner: "owner",
repoName: "repo",
repoBranch: "main",
};
}
function createQueryClient() {
return new QueryClient({
defaultOptions: {
mutations: { retry: false },
queries: { retry: false },
},
});
}
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
// Regression coverage for issue #2139: when a user double-clicks the import
// button (or the mutation otherwise fires twice with the same payload), the
// installed cache must not accumulate duplicate entries for the same skill.
describe("mergeImportedSkills", () => {
it("returns the imported list as-is when no cache exists yet", () => {
const imported = [makeSkill()];
expect(mergeImportedSkills(undefined, imported)).toEqual(imported);
});
it("dedupes by id when the same skill is imported twice in a row", () => {
const existing = [makeSkill()];
const secondImport = [makeSkill()];
const merged = mergeImportedSkills(existing, secondImport);
expect(merged).toHaveLength(1);
expect(merged[0]).toBe(secondImport[0]);
});
it("replaces stale cache entries with fresh imports for the same id", () => {
const stale = [makeSkill({ name: "Stale Name" })];
const fresh = [makeSkill({ name: "Fresh Name" })];
const merged = mergeImportedSkills(stale, fresh);
expect(merged).toHaveLength(1);
expect(merged[0].name).toBe("Fresh Name");
});
it("returns the existing reference unchanged when the imported list is empty", () => {
const existing = [makeSkill()];
expect(mergeImportedSkills(existing, [])).toBe(existing);
});
it("appends newly imported skills without dropping existing unrelated ones", () => {
const existing = [makeSkill({ id: "skill-a", directory: "skill-a" })];
const imported = [
makeSkill({ id: "skill-b", directory: "skill-b", name: "Skill B" }),
];
const merged = mergeImportedSkills(existing, imported);
expect(merged.map((s) => s.id).sort()).toEqual(["skill-a", "skill-b"]);
});
it("dedupes repeated IDs within the incoming list and keeps the last value", () => {
const first = makeSkill({ name: "First Value" });
const last = makeSkill({ name: "Last Value" });
const merged = mergeImportedSkills(undefined, [first, last]);
expect(merged).toEqual([last]);
});
it("also removes duplicate IDs already present in stale cache data", () => {
const stale = makeSkill({ name: "Stale Value" });
const newer = makeSkill({ name: "Newer Value" });
const imported = makeSkill({ id: "skill-b", name: "Skill B" });
const merged = mergeImportedSkills([stale, newer], [imported]);
expect(merged).toEqual([newer, imported]);
});
});
describe("Skills install and import mutation hooks", () => {
beforeEach(() => {
apiMocks.importFromApps.mockReset();
apiMocks.installFromZip.mockReset();
apiMocks.installUnified.mockReset();
apiMocks.uninstallUnified.mockReset();
});
it("merges a successful install by ID without mutating discoverable cache", async () => {
const queryClient = createQueryClient();
const stale = makeSkill({ name: "Stale Skill" });
const unrelated = makeSkill({ id: "skill-b", name: "Skill B" });
const installed = makeSkill({ name: "Fresh Skill" });
const discoverable = [makeDiscoverableSkill()];
queryClient.setQueryData(["skills", "installed"], [stale, unrelated]);
queryClient.setQueryData(["skills", "discoverable"], discoverable);
apiMocks.installUnified.mockResolvedValueOnce(installed);
const { result } = renderHook(() => useInstallSkill(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync({
skill: makeDiscoverableSkill(),
currentApp: "claude",
});
});
expect(
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
).toEqual([installed, unrelated]);
expect(queryClient.getQueryData(["skills", "discoverable"])).toBe(
discoverable,
);
});
it("merges ZIP results without duplicate IDs", async () => {
const queryClient = createQueryClient();
const stale = makeSkill({ name: "Stale Skill" });
const first = makeSkill({ name: "First ZIP Value" });
const last = makeSkill({ name: "Last ZIP Value" });
const second = makeSkill({ id: "skill-b", name: "Skill B" });
queryClient.setQueryData(["skills", "installed"], [stale]);
apiMocks.installFromZip.mockResolvedValueOnce([first, last, second]);
const { result } = renderHook(() => useInstallSkillsFromZip(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync({
filePath: "C:\\skills.zip",
currentApp: "claude",
});
});
expect(
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
).toEqual([last, second]);
});
it("merges imported results and refreshes every affected collection", async () => {
const queryClient = createQueryClient();
const stale = makeSkill({ name: "Stale Skill" });
const imported = makeSkill({ name: "Imported Skill" });
queryClient.setQueryData(["skills", "installed"], [stale]);
apiMocks.importFromApps.mockResolvedValueOnce([imported, imported]);
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useImportSkillsFromApps(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync([]);
});
expect(
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
).toEqual([imported]);
for (const queryKey of [
["skills", "installed"],
["skills", "unmanaged"],
["skills", "repos"],
["skills", "discoverable"],
]) {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey });
}
});
it("keeps a rejected install pending until installed and unmanaged refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
const queryClient = createQueryClient();
apiMocks.installUnified.mockRejectedValueOnce(new Error("sync failed"));
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useInstallSkill(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<InstalledSkill>;
act(() => {
mutation = result.current.mutateAsync({
skill: makeDiscoverableSkill(),
currentApp: "claude",
});
void mutation.catch(() => undefined);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
expect(result.current.isPending).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "installed"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "unmanaged"],
});
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("sync failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("refreshes installed and unmanaged when ZIP install rejects", async () => {
const queryClient = createQueryClient();
apiMocks.installFromZip.mockRejectedValueOnce(new Error("sync failed"));
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useInstallSkillsFromZip(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await expect(
result.current.mutateAsync({
filePath: "C:\\skills.zip",
currentApp: "claude",
}),
).rejects.toThrow("sync failed");
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "installed"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "unmanaged"],
});
});
it("keeps a rejected import pending until all affected caches refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
const queryClient = createQueryClient();
apiMocks.importFromApps.mockRejectedValueOnce(new Error("import failed"));
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useImportSkillsFromApps(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<InstalledSkill[]>;
act(() => {
mutation = result.current.mutateAsync([]);
void mutation.catch(() => undefined);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(4));
expect(result.current.isPending).toBe(true);
for (const queryKey of [
["skills", "installed"],
["skills", "unmanaged"],
["skills", "repos"],
["skills", "discoverable"],
]) {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey });
}
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("import failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("does not rewrite discoverable cache when uninstall succeeds", async () => {
const queryClient = createQueryClient();
const discoverable = [makeDiscoverableSkill()];
queryClient.setQueryData(["skills", "installed"], [makeSkill()]);
queryClient.setQueryData(["skills", "discoverable"], discoverable);
apiMocks.uninstallUnified.mockResolvedValueOnce({ backupPath: undefined });
const { result } = renderHook(() => useUninstallSkill(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync("skill-a");
});
expect(
queryClient.getQueryData<InstalledSkill[]>(["skills", "installed"]),
).toEqual([]);
expect(queryClient.getQueryData(["skills", "discoverable"])).toBe(
discoverable,
);
});
});