feat(management): add searchable lists and bulk app toggles (#5967)

* feat(management): add searchable lists and bulk app toggles

* test(management): cover search and bulk toggle workflows

* fix(management): await refresh after app toggles

* fix(management): await collection refreshes

* fix(management): prevent stale concurrent writes
This commit is contained in:
Thefool authored and GitHub committed 2026-08-04 14:55:10 +08:00
1 parent 92ca95ffcd
commit 9f19d8fdf6
40 files changed
+5284 -421

No files matched your search

+283 -2
View File
@@ -1,6 +1,26 @@
import { describe, it, expect } from "vitest";
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 type { InstalledSkill } from "@/lib/api/skills";
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 {
@@ -21,6 +41,35 @@ function makeSkill(overrides: Partial<InstalledSkill> = {}): InstalledSkill {
};
}
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.
@@ -59,4 +108,236 @@ describe("mergeImportedSkills", () => {
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,
);
});
});
+244
View File
@@ -0,0 +1,244 @@
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 {
useBulkToggleMcpApp,
useDeleteMcpServer,
useImportMcpFromApps,
useToggleMcpApp,
useUpsertMcpServer,
} from "@/hooks/useMcp";
import type { McpServer } from "@/types";
const toggleAppMock = vi.hoisted(() => vi.fn());
const upsertServerMock = vi.hoisted(() => vi.fn());
const deleteServerMock = vi.hoisted(() => vi.fn());
const importFromAppsMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/mcp", () => ({
mcpApi: {
toggleApp: toggleAppMock,
upsertUnifiedServer: upsertServerMock,
deleteUnifiedServer: deleteServerMock,
importFromApps: importFromAppsMock,
},
}));
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe("MCP management mutation hooks", () => {
beforeEach(() => {
toggleAppMock.mockReset();
upsertServerMock.mockReset();
deleteServerMock.mockReset();
importFromAppsMock.mockReset();
});
it("runs bulk writes serially and invalidates the list once", async () => {
let releaseFirst: (() => void) | undefined;
let releaseInvalidation: (() => void) | undefined;
const firstPending = new Promise<void>((resolve) => {
releaseFirst = resolve;
});
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
toggleAppMock.mockImplementation(async (serverId: string) => {
if (serverId === "alpha") await firstPending;
});
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useBulkToggleMcpApp(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
serverIds: ["alpha", "beta"],
app: "claude",
enabled: true,
});
});
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(1));
releaseFirst?.();
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
expect(toggleAppMock.mock.calls).toEqual([
["alpha", "claude", true],
["beta", "claude", true],
]);
expect(invalidateSpy).toHaveBeenCalledTimes(1);
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["mcp", "all"] });
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("refreshes the list when a single live-config write fails", async () => {
toggleAppMock.mockRejectedValueOnce(new Error("write failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
const { result } = renderHook(() => useToggleMcpApp(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await expect(
result.current.mutateAsync({
serverId: "alpha",
app: "claude",
enabled: true,
}),
).rejects.toThrow("write failed");
});
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["mcp", "all"] });
});
it("keeps a single toggle pending until the refreshed list is available", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
toggleAppMock.mockResolvedValueOnce(undefined);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useToggleMcpApp(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
serverId: "alpha",
app: "claude",
enabled: true,
});
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("refreshes and stays pending when an upsert fails after persistence", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
upsertServerMock.mockRejectedValueOnce(new Error("live sync failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useUpsertMcpServer(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({ id: "alpha" } as McpServer);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("live sync failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("refreshes and stays pending when deletion fails after persistence", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
deleteServerMock.mockRejectedValueOnce(new Error("live cleanup failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useDeleteMcpServer(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync("alpha");
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("live cleanup failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("keeps an import pending until the complete server list is refreshed", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
importFromAppsMock.mockResolvedValueOnce(2);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useImportMcpFromApps(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync();
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
});
+367
View File
@@ -0,0 +1,367 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { usePromptActions } from "@/hooks/usePromptActions";
import type { AppId, Prompt } from "@/lib/api";
const mocks = vi.hoisted(() => ({
getPrompts: vi.fn(),
getCurrentFileContent: vi.fn(),
enablePrompt: vi.fn(),
upsertPrompt: vi.fn(),
deletePrompt: vi.fn(),
toastError: vi.fn(),
toastSuccess: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
promptsApi: {
getPrompts: mocks.getPrompts,
getCurrentFileContent: mocks.getCurrentFileContent,
enablePrompt: mocks.enablePrompt,
upsertPrompt: mocks.upsertPrompt,
deletePrompt: mocks.deletePrompt,
},
}));
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock("sonner", () => ({
toast: {
error: mocks.toastError,
success: mocks.toastSuccess,
},
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
}
function createDeferred<T>(): Deferred<T> {
let resolve!: Deferred<T>["resolve"];
let reject!: Deferred<T>["reject"];
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}
function makePrompts(id: string, name: string): Record<string, Prompt> {
return {
[id]: {
id,
name,
content: `${name} content`,
enabled: false,
},
};
}
function renderPromptActions(initialAppId: AppId) {
return renderHook(({ appId }: { appId: AppId }) => usePromptActions(appId), {
initialProps: { appId: initialAppId },
});
}
describe("usePromptActions reload concurrency", () => {
beforeEach(() => {
mocks.getPrompts.mockReset();
mocks.getCurrentFileContent.mockReset();
mocks.getCurrentFileContent.mockResolvedValue(null);
mocks.enablePrompt.mockReset();
mocks.enablePrompt.mockResolvedValue(undefined);
mocks.upsertPrompt.mockReset();
mocks.upsertPrompt.mockResolvedValue(undefined);
mocks.deletePrompt.mockReset();
mocks.deletePrompt.mockResolvedValue(undefined);
mocks.toastError.mockReset();
mocks.toastSuccess.mockReset();
});
it("does not let an older app request overwrite the current app", async () => {
const claudeRequest = createDeferred<Record<string, Prompt>>();
const codexRequest = createDeferred<Record<string, Prompt>>();
mocks.getPrompts.mockImplementation((appId: AppId) =>
appId === "claude" ? claudeRequest.promise : codexRequest.promise,
);
mocks.getCurrentFileContent.mockImplementation(
async (appId: AppId) => `${appId} live content`,
);
const { result, rerender } = renderPromptActions("claude");
let claudeReload!: Promise<boolean>;
act(() => {
claudeReload = result.current.reload();
});
rerender({ appId: "codex" });
let codexReload!: Promise<boolean>;
act(() => {
codexReload = result.current.reload();
});
codexRequest.resolve(makePrompts("codex-prompt", "Codex Prompt"));
await act(async () => {
await codexReload;
});
expect(result.current.prompts).toEqual(
makePrompts("codex-prompt", "Codex Prompt"),
);
expect(result.current.currentFileContent).toBe("codex live content");
claudeRequest.resolve(makePrompts("claude-prompt", "Claude Prompt"));
await act(async () => {
await claudeReload;
});
expect(result.current.prompts).toEqual(
makePrompts("codex-prompt", "Codex Prompt"),
);
expect(result.current.currentFileContent).toBe("codex live content");
expect(mocks.getCurrentFileContent).toHaveBeenCalledTimes(1);
expect(mocks.getCurrentFileContent).toHaveBeenCalledWith("codex");
});
it("keeps the newer result when same-app reloads finish out of order", async () => {
const olderRequest = createDeferred<Record<string, Prompt>>();
const newerRequest = createDeferred<Record<string, Prompt>>();
mocks.getPrompts
.mockReturnValueOnce(olderRequest.promise)
.mockReturnValueOnce(newerRequest.promise);
mocks.getCurrentFileContent.mockResolvedValue("latest live content");
const { result } = renderPromptActions("claude");
let olderReload!: Promise<boolean>;
let newerReload!: Promise<boolean>;
act(() => {
olderReload = result.current.reload();
newerReload = result.current.reload();
});
newerRequest.resolve(makePrompts("newer-prompt", "Newer Prompt"));
await act(async () => {
await newerReload;
});
olderRequest.resolve(makePrompts("older-prompt", "Older Prompt"));
await act(async () => {
await olderReload;
});
expect(result.current.prompts).toEqual(
makePrompts("newer-prompt", "Newer Prompt"),
);
expect(result.current.currentFileContent).toBe("latest live content");
expect(mocks.getCurrentFileContent).toHaveBeenCalledTimes(1);
});
it("ignores an older request error while the current app is loading", async () => {
const claudeRequest = createDeferred<Record<string, Prompt>>();
const codexRequest = createDeferred<Record<string, Prompt>>();
mocks.getPrompts.mockImplementation((appId: AppId) =>
appId === "claude" ? claudeRequest.promise : codexRequest.promise,
);
const { result, rerender } = renderPromptActions("claude");
let claudeReload!: Promise<boolean>;
act(() => {
claudeReload = result.current.reload();
});
rerender({ appId: "codex" });
let codexReload!: Promise<boolean>;
act(() => {
codexReload = result.current.reload();
});
await waitFor(() => expect(result.current.loading).toBe(true));
claudeRequest.reject(new Error("stale Claude failure"));
await act(async () => {
await claudeReload;
});
expect(result.current.loading).toBe(true);
expect(mocks.toastError).not.toHaveBeenCalled();
codexRequest.resolve(makePrompts("codex-prompt", "Codex Prompt"));
await act(async () => {
await codexReload;
});
expect(result.current.loading).toBe(false);
expect(result.current.prompts).toEqual(
makePrompts("codex-prompt", "Codex Prompt"),
);
expect(mocks.toastError).not.toHaveBeenCalled();
});
it("does not show an error when a pending reload fails after unmount", async () => {
const request = createDeferred<Record<string, Prompt>>();
mocks.getPrompts.mockReturnValue(request.promise);
const { result, unmount } = renderPromptActions("claude");
let reload!: Promise<boolean>;
act(() => {
reload = result.current.reload();
});
unmount();
request.reject(new Error("failure after unmount"));
await act(async () => {
await reload;
});
expect(mocks.toastError).not.toHaveBeenCalled();
});
it("hides the previous app prompts when the new app reload fails", async () => {
const claudePrompts = makePrompts("claude-prompt", "Claude Prompt");
mocks.getPrompts
.mockResolvedValueOnce(claudePrompts)
.mockRejectedValueOnce(new Error("Codex load failed"));
mocks.getCurrentFileContent.mockResolvedValueOnce("claude live content");
const { result, rerender } = renderPromptActions("claude");
await act(async () => {
expect(await result.current.reload()).toBe(true);
});
expect(result.current.prompts).toEqual(claudePrompts);
expect(result.current.currentFileContent).toBe("claude live content");
rerender({ appId: "codex" });
expect(result.current.prompts).toEqual({});
expect(result.current.currentFileContent).toBeNull();
await act(async () => {
expect(await result.current.reload()).toBe(false);
});
expect(result.current.loading).toBe(false);
expect(result.current.prompts).toEqual({});
expect(result.current.currentFileContent).toBeNull();
expect(mocks.toastError).toHaveBeenCalledWith("prompts.loadFailed");
});
it("does not roll back the current app when an older app toggle fails", async () => {
const claudePrompts = makePrompts("claude-prompt", "Claude Prompt");
const codexPrompts = makePrompts("codex-prompt", "Codex Prompt");
const enableRequest = createDeferred<void>();
mocks.getPrompts.mockImplementation(async (appId: AppId) =>
appId === "claude" ? claudePrompts : codexPrompts,
);
mocks.enablePrompt.mockReturnValueOnce(enableRequest.promise);
const { result, rerender } = renderPromptActions("claude");
await act(async () => {
expect(await result.current.reload()).toBe(true);
});
let togglePromise!: Promise<boolean>;
act(() => {
togglePromise = result.current.toggleEnabled("claude-prompt", true);
});
await waitFor(() => {
expect(mocks.enablePrompt).toHaveBeenCalledWith(
"claude",
"claude-prompt",
);
});
rerender({ appId: "codex" });
await act(async () => {
expect(await result.current.reload()).toBe(true);
});
expect(result.current.prompts).toEqual(codexPrompts);
enableRequest.reject(new Error("stale Claude toggle failed"));
await act(async () => {
await expect(togglePromise).rejects.toThrow("stale Claude toggle failed");
});
expect(result.current.prompts).toEqual(codexPrompts);
expect(result.current.currentFileContent).toBeNull();
});
it("keeps a saved prompt locally when the follow-up reload fails", async () => {
const initialPrompts = makePrompts("existing", "Existing Prompt");
const savedPrompt: Prompt = {
id: "saved",
name: "Saved Prompt",
content: "Saved content",
enabled: false,
};
mocks.getPrompts
.mockResolvedValueOnce(initialPrompts)
.mockRejectedValueOnce(new Error("refresh failed"));
const { result } = renderPromptActions("claude");
await act(async () => {
expect(await result.current.reload()).toBe(true);
expect(await result.current.savePrompt("saved", savedPrompt)).toBe(false);
});
expect(mocks.upsertPrompt).toHaveBeenCalledWith(
"claude",
"saved",
savedPrompt,
);
expect(result.current.prompts).toEqual({
...initialPrompts,
saved: savedPrompt,
});
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.saveSuccess", {
closeButton: true,
});
});
it("keeps a deleted prompt removed when the follow-up reload fails", async () => {
const initialPrompts = {
...makePrompts("keep", "Keep Prompt"),
...makePrompts("remove", "Remove Prompt"),
};
mocks.getPrompts
.mockResolvedValueOnce(initialPrompts)
.mockRejectedValueOnce(new Error("refresh failed"));
const { result } = renderPromptActions("claude");
await act(async () => {
expect(await result.current.reload()).toBe(true);
expect(await result.current.deletePrompt("remove")).toBe(false);
});
expect(mocks.deletePrompt).toHaveBeenCalledWith("claude", "remove");
expect(result.current.prompts).toEqual(makePrompts("keep", "Keep Prompt"));
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.deleteSuccess", {
closeButton: true,
});
});
it("keeps an optimistic toggle when the follow-up reload fails", async () => {
const initialPrompts = makePrompts("toggle", "Toggle Prompt");
mocks.getPrompts
.mockResolvedValueOnce(initialPrompts)
.mockRejectedValueOnce(new Error("refresh failed"));
const { result } = renderPromptActions("claude");
await act(async () => {
expect(await result.current.reload()).toBe(true);
});
await act(async () => {
expect(await result.current.toggleEnabled("toggle", true)).toBe(false);
});
expect(mocks.enablePrompt).toHaveBeenCalledWith("claude", "toggle");
expect(result.current.prompts.toggle.enabled).toBe(true);
expect(mocks.toastSuccess).toHaveBeenCalledWith("prompts.enableSuccess", {
closeButton: true,
});
});
});
+366
View File
@@ -0,0 +1,366 @@
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 {
useBulkToggleSkillApp,
useDeleteSkillBackup,
useRestoreSkillBackup,
useToggleSkillApp,
useUninstallSkill,
useUpdateSkill,
} from "@/hooks/useSkills";
import type { SkillBackupEntry, SkillUpdateInfo } from "@/lib/api/skills";
const toggleAppMock = vi.hoisted(() => vi.fn());
const restoreBackupMock = vi.hoisted(() => vi.fn());
const uninstallMock = vi.hoisted(() => vi.fn());
const deleteBackupMock = vi.hoisted(() => vi.fn());
const updateSkillMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/api/skills", () => ({
skillsApi: {
toggleApp: toggleAppMock,
restoreBackup: restoreBackupMock,
uninstallUnified: uninstallMock,
deleteBackup: deleteBackupMock,
updateSkill: updateSkillMock,
},
}));
function createWrapper(queryClient: QueryClient) {
return function Wrapper({ children }: PropsWithChildren) {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}
describe("Skills management mutation hooks", () => {
beforeEach(() => {
toggleAppMock.mockReset();
restoreBackupMock.mockReset();
uninstallMock.mockReset();
deleteBackupMock.mockReset();
updateSkillMock.mockReset();
});
it("stays pending until the refreshed skill list is available", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
toggleAppMock.mockResolvedValue(undefined);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useBulkToggleSkillApp(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
ids: ["alpha", "beta"],
app: "claude",
enabled: true,
});
});
await waitFor(() => expect(toggleAppMock).toHaveBeenCalledTimes(2));
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "installed"],
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("keeps a single toggle pending until the refreshed list is available", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
toggleAppMock.mockResolvedValueOnce(undefined);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useToggleSkillApp(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
id: "alpha",
app: "claude",
enabled: true,
});
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("keeps backup restore pending until installed skills and backups refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
restoreBackupMock.mockResolvedValueOnce(undefined);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useRestoreSkillBackup(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
backupId: "backup-1",
currentApp: "claude",
});
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "installed"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "backups"],
});
expect(result.current.isPending).toBe(true);
releaseInvalidation?.();
await act(async () => {
await mutation;
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("also refreshes installed skills and backups when restore rejects", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
restoreBackupMock.mockRejectedValueOnce(new Error("sync failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useRestoreSkillBackup(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync({
backupId: "backup-1",
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", "backups"],
});
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("sync failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("removes an uninstalled Skill from cached update results", async () => {
uninstallMock.mockResolvedValueOnce({ backupPath: null });
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
queryClient.setQueryData<SkillUpdateInfo[]>(
["skills", "updates"],
[
{ id: "alpha", name: "Alpha", remoteHash: "alpha-remote" },
{ id: "beta", name: "Beta", remoteHash: "beta-remote" },
],
);
const { result } = renderHook(() => useUninstallSkill(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await result.current.mutateAsync("alpha");
});
expect(
queryClient.getQueryData<SkillUpdateInfo[]>(["skills", "updates"]),
).toEqual([{ id: "beta", name: "Beta", remoteHash: "beta-remote" }]);
});
it("keeps a rejected uninstall pending until backups and unmanaged Skills refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
uninstallMock.mockRejectedValueOnce(new Error("remove failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useUninstallSkill(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync("alpha");
void mutation.catch(() => undefined);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(2));
expect(result.current.isPending).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "backups"],
});
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "unmanaged"],
});
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("remove failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("keeps a rejected update pending until backups refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
updateSkillMock.mockRejectedValueOnce(new Error("replace failed"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useUpdateSkill(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync("alpha");
void mutation.catch(() => undefined);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "backups"],
});
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("replace failed");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("keeps a rejected backup deletion pending until backups refresh", async () => {
let releaseInvalidation: (() => void) | undefined;
const invalidationPending = new Promise<void>((resolve) => {
releaseInvalidation = resolve;
});
deleteBackupMock.mockRejectedValueOnce(new Error("partial delete"));
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
const invalidateSpy = vi
.spyOn(queryClient, "invalidateQueries")
.mockImplementation(() => invalidationPending);
const { result } = renderHook(() => useDeleteSkillBackup(), {
wrapper: createWrapper(queryClient),
});
let mutation!: Promise<unknown>;
act(() => {
mutation = result.current.mutateAsync("backup-1");
void mutation.catch(() => undefined);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalledTimes(1));
expect(result.current.isPending).toBe(true);
expect(invalidateSpy).toHaveBeenCalledWith({
queryKey: ["skills", "backups"],
});
releaseInvalidation?.();
await act(async () => {
await expect(mutation).rejects.toThrow("partial delete");
});
await waitFor(() => expect(result.current.isPending).toBe(false));
});
it("removes a successfully deleted backup before a refresh failure", async () => {
deleteBackupMock.mockResolvedValueOnce(true);
const queryClient = new QueryClient({
defaultOptions: { mutations: { retry: false } },
});
queryClient.setQueryData<SkillBackupEntry[]>(
["skills", "backups"],
[
{ backupId: "backup-1" } as SkillBackupEntry,
{ backupId: "backup-2" } as SkillBackupEntry,
],
);
vi.spyOn(queryClient, "invalidateQueries").mockRejectedValueOnce(
new Error("refresh failed"),
);
const { result } = renderHook(() => useDeleteSkillBackup(), {
wrapper: createWrapper(queryClient),
});
await act(async () => {
await expect(result.current.mutateAsync("backup-1")).rejects.toThrow(
"refresh failed",
);
});
expect(
queryClient.getQueryData<SkillBackupEntry[]>(["skills", "backups"]),
).toEqual([{ backupId: "backup-2" }]);
});
});