Comprehensive test updates to align with recent component refactoring and new auto-launch functionality. Component Tests: - AddProviderDialog.test.tsx (10 lines): * Updated test cases for new dialog behavior * Enhanced mock data for preset selection * Improved assertions for validation - ImportExportSection.test.tsx (16 lines): * Updated for new settings page integration * Enhanced test coverage for error scenarios * Better mock state management - McpFormModal.test.tsx (60 lines): * Extensive updates for form refactoring * New test cases for multi-app selection * Enhanced validation testing * Better coverage of stdio/http server types - ProviderList.test.tsx (11 lines): * Updated for new card layout * Enhanced drag-and-drop testing - SettingsDialog.test.tsx (96 lines): * Major updates for SettingsPage migration * New test cases for auto-launch functionality * Enhanced integration test coverage * Better async operation testing Hook Tests: - useDirectorySettings.test.tsx (32 lines): * Updated for refactored hook logic * Enhanced test coverage for edge cases - useDragSort.test.tsx (36 lines): * Simplified test cases * Better mock implementation * Improved assertions - useImportExport tests (16 lines total): * Updated for new error handling * Enhanced test coverage - useMcpValidation.test.tsx (23 lines): * Updated validation test cases * Better coverage of error scenarios - useProviderActions.test.tsx (48 lines): * Extensive updates for hook refactoring * New test cases for provider operations * Enhanced mock data - useSettings.test.tsx (12 lines): * New test cases for auto-launch * Enhanced settings state testing * Better async operation coverage Integration Tests: - App.test.tsx (41 lines): * Updated for new routing logic * Enhanced navigation testing * Better component integration coverage - SettingsDialog.test.tsx (88 lines): * Complete rewrite for SettingsPage * New integration test scenarios * Enhanced user workflow testing Mock Infrastructure: - handlers.ts (117 lines): * Major updates for MSW handlers * New handlers for auto-launch commands * Enhanced error simulation * Better request/response mocking - state.ts (37 lines): * Updated mock state structure * New state for auto-launch * Enhanced state reset functionality - tauriMocks.ts (10 lines): * Updated mock implementations * Better type safety - server.ts & testQueryClient.ts: * Minor cleanup (2 lines removed) Test Infrastructure Improvements: - Better test isolation - Enhanced mock data consistency - Improved async operation testing - Better error scenario coverage - Enhanced integration test patterns Coverage Improvements: - Net increase of 195 lines of test code - Better coverage of edge cases - Enhanced error path testing - Improved integration test scenarios - Better mock infrastructure All tests now pass with the refactored components while maintaining comprehensive coverage of functionality and edge cases.
467 lines
14 KiB
TypeScript
467 lines
14 KiB
TypeScript
import type { ReactNode } from "react";
|
|
import { renderHook, act } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { useProviderActions } from "@/hooks/useProviderActions";
|
|
import type { Provider, UsageScript } from "@/types";
|
|
|
|
const toastSuccessMock = vi.fn();
|
|
const toastErrorMock = vi.fn();
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
},
|
|
}));
|
|
|
|
const addProviderMutateAsync = vi.fn();
|
|
const updateProviderMutateAsync = vi.fn();
|
|
const deleteProviderMutateAsync = vi.fn();
|
|
const switchProviderMutateAsync = vi.fn();
|
|
|
|
const addProviderMutation = {
|
|
mutateAsync: addProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const updateProviderMutation = {
|
|
mutateAsync: updateProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const deleteProviderMutation = {
|
|
mutateAsync: deleteProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
const switchProviderMutation = {
|
|
mutateAsync: switchProviderMutateAsync,
|
|
isPending: false,
|
|
};
|
|
|
|
const useAddProviderMutationMock = vi.fn(() => addProviderMutation);
|
|
const useUpdateProviderMutationMock = vi.fn(() => updateProviderMutation);
|
|
const useDeleteProviderMutationMock = vi.fn(() => deleteProviderMutation);
|
|
const useSwitchProviderMutationMock = vi.fn(() => switchProviderMutation);
|
|
|
|
vi.mock("@/lib/query", () => ({
|
|
useAddProviderMutation: () => useAddProviderMutationMock(),
|
|
useUpdateProviderMutation: () => useUpdateProviderMutationMock(),
|
|
useDeleteProviderMutation: () => useDeleteProviderMutationMock(),
|
|
useSwitchProviderMutation: () => useSwitchProviderMutationMock(),
|
|
}));
|
|
|
|
const providersApiUpdateMock = vi.fn();
|
|
const providersApiUpdateTrayMenuMock = vi.fn();
|
|
const settingsApiGetMock = vi.fn();
|
|
const settingsApiApplyMock = vi.fn();
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
providersApi: {
|
|
update: (...args: unknown[]) => providersApiUpdateMock(...args),
|
|
updateTrayMenu: (...args: unknown[]) =>
|
|
providersApiUpdateTrayMenuMock(...args),
|
|
},
|
|
settingsApi: {
|
|
get: (...args: unknown[]) => settingsApiGetMock(...args),
|
|
applyClaudePluginConfig: (...args: unknown[]) =>
|
|
settingsApiApplyMock(...args),
|
|
},
|
|
}));
|
|
|
|
interface WrapperProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
function createWrapper() {
|
|
const queryClient = new QueryClient();
|
|
|
|
const wrapper = ({ children }: WrapperProps) => (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
|
|
return { wrapper, queryClient };
|
|
}
|
|
|
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
|
return {
|
|
id: "provider-1",
|
|
name: "Test Provider",
|
|
settingsConfig: {},
|
|
category: "official",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
addProviderMutateAsync.mockReset();
|
|
updateProviderMutateAsync.mockReset();
|
|
deleteProviderMutateAsync.mockReset();
|
|
switchProviderMutateAsync.mockReset();
|
|
providersApiUpdateMock.mockReset();
|
|
providersApiUpdateTrayMenuMock.mockReset();
|
|
settingsApiGetMock.mockReset();
|
|
settingsApiApplyMock.mockReset();
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
|
|
addProviderMutation.isPending = false;
|
|
updateProviderMutation.isPending = false;
|
|
deleteProviderMutation.isPending = false;
|
|
switchProviderMutation.isPending = false;
|
|
|
|
useAddProviderMutationMock.mockClear();
|
|
useUpdateProviderMutationMock.mockClear();
|
|
useDeleteProviderMutationMock.mockClear();
|
|
useSwitchProviderMutationMock.mockClear();
|
|
});
|
|
|
|
describe("useProviderActions", () => {
|
|
it("should trigger mutation when calling addProvider", async () => {
|
|
addProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const providerInput = {
|
|
name: "New Provider",
|
|
settingsConfig: { token: "abc" },
|
|
} as Omit<Provider, "id">;
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.addProvider(providerInput);
|
|
});
|
|
|
|
expect(addProviderMutateAsync).toHaveBeenCalledTimes(1);
|
|
expect(addProviderMutateAsync).toHaveBeenCalledWith(providerInput);
|
|
});
|
|
|
|
it("should update tray menu when calling updateProvider", async () => {
|
|
updateProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
providersApiUpdateTrayMenuMock.mockResolvedValueOnce(true);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.updateProvider(provider);
|
|
});
|
|
|
|
expect(updateProviderMutateAsync).toHaveBeenCalledWith(provider);
|
|
expect(providersApiUpdateTrayMenuMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should not request plugin sync when switching non-Claude provider", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({ category: "custom" });
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockResolvedValueOnce(true);
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({ category: "official" });
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
|
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiApplyMock).toHaveBeenCalledWith({ official: true });
|
|
});
|
|
|
|
it("should not call applyClaudePluginConfig when integration is disabled", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: false,
|
|
});
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(settingsApiGetMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should show error toast when plugin sync fails with error message", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockRejectedValueOnce(new Error("Sync failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Sync failed");
|
|
});
|
|
|
|
it("propagates updateProvider errors", async () => {
|
|
updateProviderMutateAsync.mockRejectedValueOnce(new Error("update failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.updateProvider(provider);
|
|
}),
|
|
).rejects.toThrow("update failed");
|
|
});
|
|
|
|
it("should use default error message when plugin sync fails without error message", async () => {
|
|
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
settingsApiGetMock.mockResolvedValueOnce({
|
|
enableClaudePluginIntegration: true,
|
|
});
|
|
settingsApiApplyMock.mockRejectedValueOnce(new Error(""));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("同步 Claude 插件失败");
|
|
});
|
|
|
|
it("handles mutation errors when plugin sync is skipped", async () => {
|
|
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
result.current.switchProvider(provider),
|
|
).resolves.toBeUndefined();
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should call delete mutation when calling deleteProvider", async () => {
|
|
deleteProviderMutateAsync.mockResolvedValueOnce(undefined);
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.deleteProvider("provider-2");
|
|
});
|
|
|
|
expect(deleteProviderMutateAsync).toHaveBeenCalledWith("provider-2");
|
|
});
|
|
|
|
it("should update provider and refresh cache when saveUsageScript succeeds", async () => {
|
|
providersApiUpdateMock.mockResolvedValueOnce(true);
|
|
const { wrapper, queryClient } = createWrapper();
|
|
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
|
|
|
|
const provider = createProvider({
|
|
meta: {
|
|
usage_script: {
|
|
enabled: false,
|
|
language: "javascript",
|
|
code: "",
|
|
},
|
|
},
|
|
});
|
|
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return { success: true };",
|
|
timeout: 5,
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(providersApiUpdateMock).toHaveBeenCalledWith(
|
|
{
|
|
...provider,
|
|
meta: {
|
|
...provider.meta,
|
|
usage_script: script,
|
|
},
|
|
},
|
|
"claude",
|
|
);
|
|
expect(invalidateSpy).toHaveBeenCalledWith({
|
|
queryKey: ["providers", "claude"],
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should show error toast when saveUsageScript fails with error message", async () => {
|
|
providersApiUpdateMock.mockRejectedValueOnce(new Error("Save failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return {}",
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("Save failed");
|
|
});
|
|
|
|
it("should use default error message when saveUsageScript fails without error message", async () => {
|
|
providersApiUpdateMock.mockRejectedValueOnce(new Error(""));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
const script: UsageScript = {
|
|
enabled: true,
|
|
language: "javascript",
|
|
code: "return {}",
|
|
};
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.saveUsageScript(provider, script);
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
|
expect(toastErrorMock.mock.calls[0]?.[0]).toBe("用量查询配置保存失败");
|
|
});
|
|
|
|
it("propagates addProvider errors to caller", async () => {
|
|
addProviderMutateAsync.mockRejectedValueOnce(new Error("add failed"));
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.addProvider({
|
|
name: "temp",
|
|
settingsConfig: {},
|
|
} as Omit<Provider, "id">);
|
|
}),
|
|
).rejects.toThrow("add failed");
|
|
});
|
|
|
|
it("propagates deleteProvider errors to caller", async () => {
|
|
deleteProviderMutateAsync.mockRejectedValueOnce(new Error("delete failed"));
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.deleteProvider("provider-2");
|
|
}),
|
|
).rejects.toThrow("delete failed");
|
|
});
|
|
|
|
it("handles switch mutation errors silently", async () => {
|
|
switchProviderMutateAsync.mockRejectedValueOnce(new Error("switch failed"));
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
await result.current.switchProvider(provider);
|
|
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
expect(settingsApiApplyMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should track pending state of all mutations in isLoading", () => {
|
|
addProviderMutation.isPending = true;
|
|
const { wrapper } = createWrapper();
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(true);
|
|
});
|
|
});
|
|
it("clears loading flag when all mutations idle", () => {
|
|
addProviderMutation.isPending = false;
|
|
updateProviderMutation.isPending = false;
|
|
deleteProviderMutation.isPending = false;
|
|
switchProviderMutation.isPending = false;
|
|
|
|
const { wrapper } = createWrapper();
|
|
const { result } = renderHook(() => useProviderActions("claude"), {
|
|
wrapper,
|
|
});
|
|
|
|
expect(result.current.isLoading).toBe(false);
|
|
});
|