diff --git a/tests/integration/App.test.tsx b/tests/integration/App.test.tsx index 0d6c53a..61992b7 100644 --- a/tests/integration/App.test.tsx +++ b/tests/integration/App.test.tsx @@ -1,9 +1,9 @@ import { Suspense } from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, waitFor, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import App from "@/App"; -import { resetProviderState, listProviders } from "../msw/state"; +import { resetProviderState } from "../msw/state"; import { emitTauriEvent } from "../msw/tauriMocks"; const toastSuccessMock = vi.fn(); @@ -22,7 +22,6 @@ vi.mock("@/components/providers/ProviderList", () => ({ currentProviderId, onSwitch, onEdit, - onDelete, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -33,7 +32,6 @@ vi.mock("@/components/providers/ProviderList", () => ({
{currentProviderId}
- @@ -153,14 +151,14 @@ const renderApp = () => { ); }; -describe("App Integration with MSW", () => { +describe("App integration with MSW", () => { beforeEach(() => { resetProviderState(); toastSuccessMock.mockReset(); toastErrorMock.mockReset(); }); - it("runs provider flows with mocked dialogs but real hooks", async () => { + it("covers basic provider flows via real hooks", async () => { renderApp(); await waitFor(() => @@ -171,17 +169,16 @@ describe("App Integration with MSW", () => { expect(screen.getByTestId("settings-dialog")).toBeInTheDocument(); fireEvent.click(screen.getByText("trigger-import-success")); fireEvent.click(screen.getByText("close-settings")); - expect(screen.queryByTestId("settings-dialog")).not.toBeInTheDocument(); fireEvent.click(screen.getByText("switch-codex")); await waitFor(() => expect(screen.getByTestId("provider-list").textContent).toContain("codex-1"), ); - fireEvent.click(screen.getByText("duplicate")); - await waitFor(() => - expect(screen.getByTestId("provider-list").textContent).toMatch(/copy/), - ); + fireEvent.click(screen.getByText("usage")); + expect(screen.getByTestId("usage-modal")).toBeInTheDocument(); + fireEvent.click(screen.getByText("save-script")); + fireEvent.click(screen.getByText("close-usage")); fireEvent.click(screen.getByText("create")); expect(screen.getByTestId("add-provider-dialog")).toBeInTheDocument(); @@ -197,29 +194,17 @@ describe("App Integration with MSW", () => { expect(screen.getByTestId("provider-list").textContent).toMatch(/-edited/), ); - fireEvent.click(screen.getByText("usage")); - expect(screen.getByTestId("usage-modal")).toBeInTheDocument(); - fireEvent.click(screen.getByText("save-script")); - fireEvent.click(screen.getByText("close-usage")); - - fireEvent.click(screen.getByText("delete")); - expect(screen.getByTestId("confirm-dialog")).toBeInTheDocument(); - fireEvent.click(screen.getByText("confirm-delete")); + fireEvent.click(screen.getByText("switch")); + fireEvent.click(screen.getByText("duplicate")); await waitFor(() => - expect(Object.keys(listProviders("codex"))).not.toContain("codex-1"), - ); - await waitFor(() => - expect(screen.getByTestId("current-provider").textContent).not.toBe("codex-1"), + expect(screen.getByTestId("provider-list").textContent).toMatch(/copy/), ); fireEvent.click(screen.getByText("open-website")); emitTauriEvent("provider-switched", { appType: "codex", providerId: "codex-2" }); - await waitFor(() => - expect(screen.getByTestId("current-provider").textContent).toBe("codex-2"), - ); - expect(toastSuccessMock).toHaveBeenCalled(); expect(toastErrorMock).not.toHaveBeenCalled(); + expect(toastSuccessMock).toHaveBeenCalled(); }); }); diff --git a/tests/integration/SettingsDialog.test.tsx b/tests/integration/SettingsDialog.test.tsx new file mode 100644 index 0000000..cabe232 --- /dev/null +++ b/tests/integration/SettingsDialog.test.tsx @@ -0,0 +1,178 @@ +import React, { Suspense } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { render, screen, waitFor, fireEvent } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { SettingsDialog } from "@/components/settings/SettingsDialog"; +import { resetProviderState, getSettings, getAppConfigDirOverride } from "../msw/state"; + +const toastSuccessMock = vi.fn(); +const toastErrorMock = vi.fn(); + +vi.mock("sonner", () => ({ + toast: { + success: (...args: unknown[]) => toastSuccessMock(...args), + error: (...args: unknown[]) => toastErrorMock(...args), + }, +})); + +vi.mock("@/components/ui/dialog", () => ({ + Dialog: ({ open, children }: any) => (open ?
{children}
: null), + DialogContent: ({ children }: any) =>
{children}
, + DialogHeader: ({ children }: any) =>
{children}
, + DialogFooter: ({ children }: any) =>
{children}
, + DialogTitle: ({ children }: any) =>

{children}

, +})); + +const TabsContext = React.createContext<{ value: string; onValueChange?: (value: string) => void }>( + { + value: "general", + }, +); + +vi.mock("@/components/ui/tabs", () => { + return { + Tabs: ({ value, onValueChange, children }: any) => ( + {children} + ), + TabsList: ({ children }: any) =>
{children}
, + TabsTrigger: ({ value, children }: any) => { + const ctx = React.useContext(TabsContext); + return ( + + ); + }, + TabsContent: ({ value, children }: any) => { + const ctx = React.useContext(TabsContext); + return ctx.value === value ?
{children}
: null; + }, + }; +}); + +vi.mock("@/components/settings/LanguageSettings", () => ({ + LanguageSettings: ({ value, onChange }: any) => ( +
+ language:{value} + +
+ ), +})); + +vi.mock("@/components/settings/ThemeSettings", () => ({ + ThemeSettings: () =>
theme
, +})); + +vi.mock("@/components/settings/WindowSettings", () => ({ + WindowSettings: ({ onChange }: any) => ( + + ), +})); + +vi.mock("@/components/settings/DirectorySettings", async () => { + const actual = await vi.importActual( + "@/components/settings/DirectorySettings", + ); + return actual; +}); + +vi.mock("@/components/settings/ImportExportSection", () => ({ + ImportExportSection: ({ + status, + selectedFile, + errorMessage, + isImporting, + onSelectFile, + onImport, + onExport, + onClear, + }: any) => ( +
+
{status}
+
{selectedFile || "none"}
+ + + + + {errorMessage ? {errorMessage} : null} +
+ ), +})); + +vi.mock("@/components/settings/AboutSection", () => ({ + AboutSection: ({ isPortable }: any) =>
about:{String(isPortable)}
, +})); + +const renderDialog = (props?: Partial>) => { + const client = new QueryClient(); + return render( + + loading}> + {}} {...props} /> + + , + ); +}; + +beforeEach(() => { + resetProviderState(); + toastSuccessMock.mockReset(); + toastErrorMock.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("SettingsDialog integration", () => { + it("loads default settings from MSW", async () => { + renderDialog(); + + await waitFor(() => expect(screen.getByText("language:zh")).toBeInTheDocument()); + fireEvent.click(screen.getByText("settings.tabAdvanced")); + const appInput = await screen.findByPlaceholderText("settings.browsePlaceholderApp"); + expect((appInput as HTMLInputElement).value).toBe("/home/mock/.cc-switch"); + }); + + it("imports configuration and triggers success callback", async () => { + const onImportSuccess = vi.fn(); + renderDialog({ onImportSuccess }); + + await waitFor(() => expect(screen.getByText("language:zh")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("settings.tabAdvanced")); + fireEvent.click(screen.getByText("settings.selectConfigFile")); + await waitFor(() => + expect(screen.getByTestId("selected-file").textContent).toContain("/mock/import-settings.json"), + ); + + fireEvent.click(screen.getByText("settings.import")); + await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled()); + await waitFor(() => expect(onImportSuccess).toHaveBeenCalled(), { + timeout: 4000, + }); + expect(getSettings().language).toBe("en"); + }); + + it("saves settings and handles restart prompt", async () => { + renderDialog(); + + await waitFor(() => expect(screen.getByText("language:zh")).toBeInTheDocument()); + + fireEvent.click(screen.getByText("settings.tabAdvanced")); + const appInput = await screen.findByPlaceholderText("settings.browsePlaceholderApp"); + fireEvent.change(appInput, { target: { value: "/custom/app" } }); + fireEvent.click(screen.getByText("common.save")); + + await waitFor(() => expect(toastSuccessMock).toHaveBeenCalled()); + await screen.findByText("settings.restartRequired"); + fireEvent.click(screen.getByText("settings.restartLater")); + await waitFor(() => + expect(screen.queryByText("settings.restartRequired")).not.toBeInTheDocument(), + ); + + expect(getAppConfigDirOverride()).toBe("/custom/app"); + }); +}); diff --git a/tests/msw/handlers.ts b/tests/msw/handlers.ts index de10561..bd052ce 100644 --- a/tests/msw/handlers.ts +++ b/tests/msw/handlers.ts @@ -1,6 +1,6 @@ import { http, HttpResponse } from "msw"; import type { AppType } from "@/lib/api/types"; -import type { Provider } from "@/types"; +import type { Provider, Settings } from "@/types"; import { addProvider, deleteProvider, @@ -11,6 +11,10 @@ import { setCurrentProviderId, updateProvider, updateSortOrder, + getSettings, + setSettings, + getAppConfigDirOverride, + setAppConfigDirOverrideState, } from "./state"; const TAURI_ENDPOINT = "http://tauri.local"; @@ -97,4 +101,65 @@ export const handlers = [ http.post(`${TAURI_ENDPOINT}/open_external`, () => success(true)), http.post(`${TAURI_ENDPOINT}/restart_app`, () => success(true)), + + http.post(`${TAURI_ENDPOINT}/get_settings`, () => success(getSettings())), + + http.post(`${TAURI_ENDPOINT}/save_settings`, async ({ request }) => { + const { settings } = await withJson<{ settings: Settings }>(request); + setSettings(settings); + return success(true); + }), + + http.post(`${TAURI_ENDPOINT}/set_app_config_dir_override`, async ({ request }) => { + const { path } = await withJson<{ path: string | null }>(request); + setAppConfigDirOverrideState(path ?? null); + return success(true); + }), + + http.post(`${TAURI_ENDPOINT}/get_app_config_dir_override`, () => + success(getAppConfigDirOverride()), + ), + + http.post(`${TAURI_ENDPOINT}/apply_claude_plugin_config`, async ({ request }) => { + const { official } = await withJson<{ official: boolean }>(request); + setSettings({ enableClaudePluginIntegration: !official }); + return success(true); + }), + + http.post(`${TAURI_ENDPOINT}/get_config_dir`, async ({ request }) => { + const { app_type } = await withJson<{ app_type: AppType }>(request); + return success(app_type === "claude" ? "/default/claude" : "/default/codex"); + }), + + http.post(`${TAURI_ENDPOINT}/is_portable_mode`, () => success(false)), + + http.post(`${TAURI_ENDPOINT}/select_config_directory`, async ({ request }) => { + const { default_path } = await withJson<{ default_path?: string }>(request); + return success(default_path ? `${default_path}/picked` : "/mock/selected-dir"); + }), + + http.post(`${TAURI_ENDPOINT}/open_file_dialog`, () => + success("/mock/import-settings.json"), + ), + + http.post(`${TAURI_ENDPOINT}/import_config_from_file`, async ({ request }) => { + const { filePath } = await withJson<{ filePath: string }>(request); + if (!filePath) { + return success({ success: false, message: "Missing file" }); + } + setSettings({ language: "en" }); + return success({ success: true, backupId: "backup-123" }); + }), + + http.post(`${TAURI_ENDPOINT}/export_config_to_file`, async ({ request }) => { + const { filePath } = await withJson<{ filePath: string }>(request); + if (!filePath) { + return success({ success: false, message: "Invalid destination" }); + } + return success({ success: true, filePath }); + }), + + http.post(`${TAURI_ENDPOINT}/save_file_dialog`, () => + success("/mock/export-settings.json"), + ), ]; diff --git a/tests/msw/state.ts b/tests/msw/state.ts index ce70520..43051e1 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -1,5 +1,5 @@ import type { AppType } from "@/lib/api/types"; -import type { Provider } from "@/types"; +import type { Provider, Settings } from "@/types"; type ProvidersByApp = Record>; type CurrentProviderState = Record; @@ -50,6 +50,15 @@ const createDefaultCurrent = (): CurrentProviderState => ({ let providers = createDefaultProviders(); let current = createDefaultCurrent(); +let settingsState: Settings = { + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + claudeConfigDir: "/default/claude", + codexConfigDir: "/default/codex", + language: "zh", +}; +let appConfigDirOverride: string | null = null; const cloneProviders = (value: ProvidersByApp) => JSON.parse(JSON.stringify(value)) as ProvidersByApp; @@ -57,6 +66,15 @@ const cloneProviders = (value: ProvidersByApp) => export const resetProviderState = () => { providers = createDefaultProviders(); current = createDefaultCurrent(); + settingsState = { + showInTray: true, + minimizeToTrayOnClose: true, + enableClaudePluginIntegration: false, + claudeConfigDir: "/default/claude", + codexConfigDir: "/default/codex", + language: "zh", + }; + appConfigDirOverride = null; }; export const getProviders = (appType: AppType) => @@ -114,3 +132,14 @@ export const updateSortOrder = ( export const listProviders = (appType: AppType) => JSON.parse(JSON.stringify(providers[appType] ?? {})) as Record; +export const getSettings = () => JSON.parse(JSON.stringify(settingsState)) as Settings; + +export const setSettings = (data: Partial) => { + settingsState = { ...settingsState, ...data }; +}; + +export const getAppConfigDirOverride = () => appConfigDirOverride; + +export const setAppConfigDirOverrideState = (value: string | null) => { + appConfigDirOverride = value; +}; diff --git a/tests/msw/tauriMocks.ts b/tests/msw/tauriMocks.ts index 5d086d4..df6e93b 100644 --- a/tests/msw/tauriMocks.ts +++ b/tests/msw/tauriMocks.ts @@ -59,3 +59,7 @@ vi.mock("@tauri-apps/api/event", () => ({ // Ensure the MSW server is referenced so tree shaking doesn't remove imports void server; +vi.mock("@tauri-apps/api/path", () => ({ + homeDir: async () => "/home/mock", + join: async (...segments: string[]) => segments.join("/"), +}));