diff --git a/tests/components/SettingsDialog.test.tsx b/tests/components/SettingsDialog.test.tsx index 844309f..c1a45a1 100644 --- a/tests/components/SettingsDialog.test.tsx +++ b/tests/components/SettingsDialog.test.tsx @@ -1,5 +1,5 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { createContext, useContext } from "react"; import { SettingsDialog } from "@/components/settings/SettingsDialog"; @@ -104,13 +104,16 @@ const createImportExportMock = (overrides: Partial = {}) => { let settingsMock = createSettingsMock(); let importExportMock = createImportExportMock(); +const useImportExportSpy = vi.fn(); +let lastUseImportExportOptions: Record | undefined; vi.mock("@/hooks/useSettings", () => ({ useSettings: () => settingsMock, })); vi.mock("@/hooks/useImportExport", () => ({ - useImportExport: () => importExportMock, + useImportExport: (options?: Record) => + useImportExportSpy(options), })); vi.mock("@/lib/api", () => ({ @@ -177,8 +180,22 @@ vi.mock("@/components/settings/WindowSettings", () => ({ })); vi.mock("@/components/settings/DirectorySettings", () => ({ - DirectorySettings: ({ onBrowseDirectory }: any) => ( - + DirectorySettings: ({ + onBrowseDirectory, + onResetDirectory, + onDirectoryChange, + onBrowseAppConfig, + onResetAppConfig, + onAppConfigChange, + }: any) => ( +
+ + + + + + +
), })); @@ -193,12 +210,22 @@ describe("SettingsDialog Component", () => { tMock.mockImplementation((key: string) => key); settingsMock = createSettingsMock(); importExportMock = createImportExportMock(); + useImportExportSpy.mockReset(); + useImportExportSpy.mockImplementation((options?: Record) => { + lastUseImportExportOptions = options; + return importExportMock; + }); + lastUseImportExportOptions = undefined; toastSuccessMock.mockReset(); toastErrorMock.mockReset(); settingsApi = (await import("@/lib/api")).settingsApi; settingsApi.restart.mockClear(); }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("should not render form content when loading", () => { settingsMock = createSettingsMock({ settings: null, isLoading: true }); @@ -208,9 +235,21 @@ describe("SettingsDialog Component", () => { expect(screen.getByText("settings.title")).toBeInTheDocument(); }); + it("should reset import/export status when dialog transitions to open", () => { + const { rerender } = render( + , + ); + + importExportMock.resetStatus.mockClear(); + + rerender(); + + expect(importExportMock.resetStatus).toHaveBeenCalledTimes(1); + }); + it("should render general and advanced tabs and trigger child callbacks", () => { const onOpenChange = vi.fn(); - importExportMock = createImportExportMock({ selectedFile: "" }); + importExportMock = createImportExportMock({ selectedFile: "/tmp/config.json" }); render(); @@ -227,6 +266,37 @@ describe("SettingsDialog Component", () => { fireEvent.click(screen.getByRole("button", { name: "settings.selectConfigFile" })); expect(importExportMock.selectImportFile).toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "settings.exportConfig" })); + expect(importExportMock.exportConfig).toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "settings.import" })); + expect(importExportMock.importConfig).toHaveBeenCalled(); + + fireEvent.click(screen.getByRole("button", { name: "common.clear" })); + expect(importExportMock.clearSelection).toHaveBeenCalled(); + }); + + it("should pass onImportSuccess callback to useImportExport hook", async () => { + const onImportSuccess = vi.fn(); + + render( + , + ); + + expect(useImportExportSpy).toHaveBeenCalledWith( + expect.objectContaining({ onImportSuccess }), + ); + expect(lastUseImportExportOptions?.onImportSuccess).toBe(onImportSuccess); + + if (typeof lastUseImportExportOptions?.onImportSuccess === "function") { + await lastUseImportExportOptions.onImportSuccess(); + } + expect(onImportSuccess).toHaveBeenCalledTimes(1); }); it("should call saveSettings and close dialog when clicking save", async () => { @@ -276,4 +346,48 @@ describe("SettingsDialog Component", () => { expect(toastSuccessMock).toHaveBeenCalledWith("settings.devModeRestartHint"); }); }); + + it("should allow postponing restart and close dialog without restarting", async () => { + const onOpenChange = vi.fn(); + settingsMock = createSettingsMock({ requiresRestart: true }); + + render(); + + expect(await screen.findByText("settings.restartRequired")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("settings.restartLater")); + + await waitFor(() => { + expect(onOpenChange).toHaveBeenCalledWith(false); + expect(settingsMock.acknowledgeRestart).toHaveBeenCalledTimes(1); + }); + + expect(settingsApi.restart).not.toHaveBeenCalled(); + expect(toastSuccessMock).not.toHaveBeenCalled(); + expect(toastErrorMock).not.toHaveBeenCalled(); + }); + + it("should trigger directory management callbacks inside advanced tab", () => { + render(); + + fireEvent.click(screen.getByText("settings.tabAdvanced")); + + fireEvent.click(screen.getByText("browse-directory")); + expect(settingsMock.browseDirectory).toHaveBeenCalledWith("claude"); + + fireEvent.click(screen.getByText("reset-directory")); + expect(settingsMock.resetDirectory).toHaveBeenCalledWith("claude"); + + fireEvent.click(screen.getByText("change-directory")); + expect(settingsMock.updateDirectory).toHaveBeenCalledWith("codex", "/new/path"); + + fireEvent.click(screen.getByText("browse-app-config")); + expect(settingsMock.browseAppConfigDir).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText("reset-app-config")); + expect(settingsMock.resetAppConfigDir).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText("change-app-config")); + expect(settingsMock.updateAppConfigDir).toHaveBeenCalledWith("/app/new"); + }); }); diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx new file mode 100644 index 0000000..6b4d050 --- /dev/null +++ b/tests/integration/App.test.tsx @@ -0,0 +1,395 @@ +import { Suspense } from "react"; +import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const { + toastSuccessMock, + toastErrorMock, + deleteProviderMock, + addProviderMock, + updateProviderMock, + saveUsageScriptMock, + onSwitchedMock, + updateSortOrderMock, + updateTrayMenuMock, + openExternalMock, + useProvidersQueryMock, + useProviderActionsMock, + providersDataMock, + getAllMock, + getCurrentMock, + importDefaultMock, +} = vi.hoisted(() => { + const deleteProviderMock = vi.fn(); + const addProviderMock = vi.fn(); + const updateProviderMock = vi.fn(); + const saveUsageScriptMock = vi.fn(); + const onSwitchedMock = vi.fn(); + const updateSortOrderMock = vi.fn(); + const updateTrayMenuMock = vi.fn(); + const openExternalMock = vi.fn(); + const useProvidersQueryMock = vi.fn(); + const getAllMock = vi.fn(); + const getCurrentMock = vi.fn(); + const importDefaultMock = vi.fn(); + const toastSuccessMock = vi.fn(); + const toastErrorMock = vi.fn(); + + const providersDataMock = { + claude: [ + { + id: "claude-1", + name: "Claude Default", + settingsConfig: {}, + category: "default", + sortIndex: 0, + }, + { + id: "claude-2", + name: "Claude Custom", + settingsConfig: {}, + category: "custom", + sortIndex: 1, + }, + ], + codex: [ + { + id: "codex-1", + name: "Codex Default", + settingsConfig: {}, + category: "default", + sortIndex: 0, + }, + { + id: "codex-2", + name: "Codex Secondary", + settingsConfig: {}, + category: "custom", + sortIndex: 1, + }, + ], + }; + + const useProviderActionsMock = vi.fn(() => ({ + addProvider: addProviderMock, + updateProvider: updateProviderMock, + deleteProvider: deleteProviderMock, + saveUsageScript: saveUsageScriptMock, + })); + + return { + toastSuccessMock, + toastErrorMock, + deleteProviderMock, + addProviderMock, + updateProviderMock, + saveUsageScriptMock, + onSwitchedMock, + updateSortOrderMock, + updateTrayMenuMock, + openExternalMock, + useProvidersQueryMock, + useProviderActionsMock, + providersDataMock, + getAllMock, + getCurrentMock, + importDefaultMock, + }; +}); + +vi.mock("@/lib/query", () => ({ + useProvidersQuery: (...args: unknown[]) => useProvidersQueryMock(...args), +})); + +vi.mock("@/hooks/useProviderActions", () => ({ + useProviderActions: () => useProviderActionsMock(), +})); + +vi.mock("@/lib/api", () => ({ + providersApi: { + onSwitched: onSwitchedMock, + updateSortOrder: updateSortOrderMock, + updateTrayMenu: updateTrayMenuMock, + getAll: getAllMock, + getCurrent: getCurrentMock, + importDefault: importDefaultMock, + }, + settingsApi: { + openExternal: openExternalMock, + }, +})); + +vi.mock("sonner", () => ({ + toast: { + success: toastSuccessMock, + error: toastErrorMock, + }, +})); + +vi.mock("react-i18next", async () => { + const actual = await vi.importActual("react-i18next"); + return { + ...actual, + useTranslation: () => ({ + t: (key: string, options?: Record) => + options?.defaultValue ?? key, + }), + }; +}); + + +vi.mock("@/components/providers/ProviderList", () => ({ + ProviderList: ({ + providers, + currentProviderId, + onSwitch, + onEdit, + onDelete, + onDuplicate, + onConfigureUsage, + onOpenWebsite, + onCreate, + }: any) => ( +
+
{JSON.stringify(providers)}
+ + + + + + + +
+ ), +})); + +vi.mock("@/components/providers/AddProviderDialog", () => ({ + AddProviderDialog: ({ open, onOpenChange, onSubmit, appType }: any) => + open ? ( +
+ + +
+ ) : null, +})); + +vi.mock("@/components/providers/EditProviderDialog", () => ({ + EditProviderDialog: ({ open, provider, onSubmit, onOpenChange }: any) => + open ? ( +
+ {provider?.id} + + +
+ ) : null, +})); + +vi.mock("@/components/UsageScriptModal", () => ({ + default: ({ isOpen, provider, onSave, onClose }: any) => + isOpen ? ( +
+ {provider?.id} + + +
+ ) : null, +})); + +vi.mock("@/components/ConfirmDialog", () => ({ + ConfirmDialog: ({ isOpen, onConfirm, onCancel }: any) => + isOpen ? ( +
+ + +
+ ) : null, +})); + +vi.mock("@/components/settings/SettingsDialog", () => ({ + SettingsDialog: ({ open, onOpenChange, onImportSuccess }: any) => + open ? ( +
+ + +
+ ) : ( + + ), +})); + +vi.mock("@/components/AppSwitcher", () => ({ + AppSwitcher: ({ activeApp, onSwitch }: any) => ( +
+ {activeApp} + +
+ ), +})); + +vi.mock("@/components/UpdateBadge", () => ({ + UpdateBadge: ({ onClick }: any) => ( + + ), +})); + +vi.mock("@/components/mcp/McpPanel", () => ({ + default: ({ open, onOpenChange }: any) => + open ? ( +
+ +
+ ) : ( + + ), +})); + +const mockRefetch = vi.fn(); + +const queryClient = new QueryClient(); +let AppComponent: typeof import("@/App").default; + +describe("App Integration", () => { + beforeAll(async () => { + const module = await import("@/App"); + AppComponent = module.default; + }); + + beforeEach(() => { + queryClient.clear(); + mockRefetch.mockReset(); + mockRefetch.mockResolvedValue(undefined); + useProvidersQueryMock.mockReset(); + getAllMock.mockReset(); + getCurrentMock.mockReset(); + importDefaultMock.mockReset(); + onSwitchedMock.mockResolvedValue(() => {}); + updateSortOrderMock.mockResolvedValue(undefined); + updateTrayMenuMock.mockResolvedValue(undefined); + openExternalMock.mockResolvedValue(undefined); + + useProvidersQueryMock.mockImplementation((appType: string) => { + const providers = providersDataMock[appType as keyof typeof providersDataMock] || []; + getAllMock.mockResolvedValue(Object.fromEntries(providers.map((provider) => [provider.id, provider]))); + getCurrentMock.mockResolvedValue(providers[0]?.id ?? ""); + importDefaultMock.mockResolvedValue(false); + return { + data: { + providers: Object.fromEntries( + providers.map((provider) => [provider.id, provider]), + ), + currentProviderId: providers[0]?.id ?? "", + }, + isLoading: false, + refetch: mockRefetch, + }; + }); + }); + + it("should render providers, open dialogs, and execute core flows", async () => { + const { container } = render( + + loading}> + + + , + ); + + // 初始加载后,应显示 ProviderList mock 渲染的 JSON + await waitFor(() => + expect(screen.getByTestId("provider-list")).toBeInTheDocument(), + ); + expect(screen.getByText("CC Switch")).toBeInTheDocument(); + + // 打开设置对话框并触发导入成功回调 + fireEvent.click(screen.getByText("update-badge")); // open settings via badge + expect(screen.getByTestId("settings-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByText("settings-on-import")); + await waitFor(() => expect(mockRefetch).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(updateTrayMenuMock).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByText("close-settings")); + expect(screen.queryByTestId("settings-dialog")).not.toBeInTheDocument(); + + // 切换到 codex 应用,确保 useProvidersQuery 被使用 + fireEvent.click(screen.getByText("switch-app")); + await waitFor(() => { + expect(useProvidersQueryMock).toHaveBeenCalledWith("codex"); + }); + + // 添加供应商流程 + fireEvent.click(screen.getByText("header.addProvider")); + expect(screen.getByTestId("add-provider-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByText("add-provider")); + expect(addProviderMock).toHaveBeenCalledWith({ name: "New Provider", appType: "codex" }); + fireEvent.click(screen.getByText("close-add")); + + // 编辑供应商流程 + fireEvent.click(screen.getByText("edit")); + expect(screen.getByTestId("edit-provider-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByText("save-edit")); + expect(updateProviderMock).toHaveBeenCalledWith({ + id: "codex-1", + name: "undefined-edited", + }); + fireEvent.click(screen.getByText("close-edit")); + + // 删除供应商流程 + fireEvent.click(screen.getByText("delete")); + expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByText("confirm-delete")); + expect(deleteProviderMock).toHaveBeenCalledWith("codex-1"); + + // 复制供应商流程(触发排序更新 + 添加) + fireEvent.click(screen.getByText("duplicate")); + await waitFor(() => { + expect(updateSortOrderMock).toHaveBeenCalled(); + }); + expect(addProviderMock).toHaveBeenCalledTimes(2); + + // 使用脚本弹窗 + fireEvent.click(screen.getByText("usage")); + expect(screen.getByTestId("usage-modal")).toBeInTheDocument(); + fireEvent.click(screen.getByText("save-script")); + expect(saveUsageScriptMock).toHaveBeenCalledWith( + { id: "codex-1" }, + "script-code", + ); + fireEvent.click(screen.getByText("close-usage")); + expect(screen.queryByTestId("usage-modal")).not.toBeInTheDocument(); + + // 打开网站链接 + fireEvent.click(screen.getByText("open-website")); + expect(openExternalMock).toHaveBeenCalledWith("https://example.com"); + + // 确保页面保留核心元素 + expect(container.textContent).toContain("CC Switch"); + }); +});