- Add postChangeSync.ts utility with Result pattern for graceful error handling - Replace try-catch with syncCurrentProvidersLiveSafe in useImportExport - Add directory-change-triggered sync in useSettings to maintain SSOT - Introduce partial-success status to distinguish import success from sync failures - Add test coverage for sync behavior in different scenarios This refactoring ensures config.json changes are reliably synced to live files while providing better user feedback for edge cases.
325 lines
9.8 KiB
TypeScript
325 lines
9.8 KiB
TypeScript
import { renderHook, act } from "@testing-library/react";
|
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
import { useSettings } from "@/hooks/useSettings";
|
|
import type { Settings } from "@/types";
|
|
|
|
const mutateAsyncMock = vi.fn();
|
|
const useSettingsQueryMock = vi.fn();
|
|
const setAppConfigDirOverrideMock = vi.fn();
|
|
const applyClaudePluginConfigMock = vi.fn();
|
|
const syncCurrentProvidersLiveMock = vi.fn();
|
|
const toastErrorMock = vi.fn();
|
|
const toastSuccessMock = vi.fn();
|
|
|
|
let settingsFormMock: any;
|
|
let directorySettingsMock: any;
|
|
let metadataMock: any;
|
|
let serverSettings: Settings;
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/hooks/useSettingsForm", () => ({
|
|
useSettingsForm: () => settingsFormMock,
|
|
}));
|
|
|
|
vi.mock("@/hooks/useDirectorySettings", () => ({
|
|
useDirectorySettings: () => directorySettingsMock,
|
|
}));
|
|
|
|
vi.mock("@/hooks/useSettingsMetadata", () => ({
|
|
useSettingsMetadata: () => metadataMock,
|
|
}));
|
|
|
|
vi.mock("@/lib/query", () => ({
|
|
useSettingsQuery: (...args: unknown[]) => useSettingsQueryMock(...args),
|
|
useSaveSettingsMutation: () => ({
|
|
mutateAsync: mutateAsyncMock,
|
|
isPending: false,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@/lib/api", () => ({
|
|
settingsApi: {
|
|
setAppConfigDirOverride: (...args: unknown[]) =>
|
|
setAppConfigDirOverrideMock(...args),
|
|
applyClaudePluginConfig: (...args: unknown[]) =>
|
|
applyClaudePluginConfigMock(...args),
|
|
syncCurrentProvidersLive: (...args: unknown[]) =>
|
|
syncCurrentProvidersLiveMock(...args),
|
|
},
|
|
}));
|
|
|
|
const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
|
|
settings: {
|
|
showInTray: true,
|
|
minimizeToTrayOnClose: true,
|
|
enableClaudePluginIntegration: false,
|
|
claudeConfigDir: "/claude",
|
|
codexConfigDir: "/codex",
|
|
language: "zh",
|
|
},
|
|
isLoading: false,
|
|
initialLanguage: "zh",
|
|
updateSettings: vi.fn(),
|
|
resetSettings: vi.fn(),
|
|
syncLanguage: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
const createDirectorySettingsMock = (overrides: Record<string, unknown> = {}) => ({
|
|
appConfigDir: undefined,
|
|
resolvedDirs: {
|
|
appConfig: "/home/mock/.cc-switch",
|
|
claude: "/default/claude",
|
|
codex: "/default/codex",
|
|
},
|
|
isLoading: false,
|
|
initialAppConfigDir: undefined,
|
|
updateDirectory: vi.fn(),
|
|
updateAppConfigDir: vi.fn(),
|
|
browseDirectory: vi.fn(),
|
|
browseAppConfigDir: vi.fn(),
|
|
resetDirectory: vi.fn(),
|
|
resetAppConfigDir: vi.fn(),
|
|
resetAllDirectories: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
const createMetadataMock = (overrides: Record<string, unknown> = {}) => ({
|
|
isPortable: false,
|
|
requiresRestart: false,
|
|
isLoading: false,
|
|
acknowledgeRestart: vi.fn(),
|
|
setRequiresRestart: vi.fn(),
|
|
...overrides,
|
|
});
|
|
|
|
describe("useSettings hook", () => {
|
|
beforeEach(() => {
|
|
mutateAsyncMock.mockReset();
|
|
useSettingsQueryMock.mockReset();
|
|
setAppConfigDirOverrideMock.mockReset();
|
|
applyClaudePluginConfigMock.mockReset();
|
|
syncCurrentProvidersLiveMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
toastSuccessMock.mockReset();
|
|
window.localStorage.clear();
|
|
|
|
serverSettings = {
|
|
showInTray: true,
|
|
minimizeToTrayOnClose: true,
|
|
enableClaudePluginIntegration: false,
|
|
claudeConfigDir: "/server/claude",
|
|
codexConfigDir: "/server/codex",
|
|
language: "zh",
|
|
};
|
|
|
|
useSettingsQueryMock.mockReturnValue({
|
|
data: serverSettings,
|
|
isLoading: false,
|
|
});
|
|
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: {
|
|
...serverSettings,
|
|
language: "zh",
|
|
},
|
|
});
|
|
directorySettingsMock = createDirectorySettingsMock();
|
|
metadataMock = createMetadataMock();
|
|
|
|
mutateAsyncMock.mockResolvedValue(true);
|
|
setAppConfigDirOverrideMock.mockResolvedValue(true);
|
|
applyClaudePluginConfigMock.mockResolvedValue(true);
|
|
});
|
|
|
|
it("saves settings and flags restart when app config directory changes", async () => {
|
|
serverSettings = {
|
|
...serverSettings,
|
|
enableClaudePluginIntegration: true,
|
|
claudeConfigDir: "/server/claude",
|
|
codexConfigDir: undefined,
|
|
language: "en",
|
|
};
|
|
useSettingsQueryMock.mockReturnValue({
|
|
data: serverSettings,
|
|
isLoading: false,
|
|
});
|
|
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: {
|
|
...serverSettings,
|
|
claudeConfigDir: " /custom/claude ",
|
|
codexConfigDir: " ",
|
|
language: "en",
|
|
enableClaudePluginIntegration: true,
|
|
},
|
|
initialLanguage: "en",
|
|
});
|
|
|
|
directorySettingsMock = createDirectorySettingsMock({
|
|
appConfigDir: " /override/app ",
|
|
initialAppConfigDir: "/previous/app",
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
let saveResult: { requiresRestart: boolean } | null = null;
|
|
await act(async () => {
|
|
saveResult = await result.current.saveSettings();
|
|
});
|
|
|
|
expect(saveResult).toEqual({ requiresRestart: true });
|
|
expect(mutateAsyncMock).toHaveBeenCalledTimes(1);
|
|
const payload = mutateAsyncMock.mock.calls[0][0] as Settings;
|
|
expect(payload.claudeConfigDir).toBe("/custom/claude");
|
|
expect(payload.codexConfigDir).toBeUndefined();
|
|
expect(payload.language).toBe("en");
|
|
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app");
|
|
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: false });
|
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
|
|
expect(window.localStorage.getItem("language")).toBe("en");
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
// 目录有变化,应触发一次同步当前供应商到 live
|
|
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("saves settings without restart when directory unchanged", async () => {
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: {
|
|
...serverSettings,
|
|
enableClaudePluginIntegration: false,
|
|
language: "zh",
|
|
},
|
|
initialLanguage: "zh",
|
|
});
|
|
|
|
directorySettingsMock = createDirectorySettingsMock({
|
|
appConfigDir: undefined,
|
|
initialAppConfigDir: undefined,
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
let saveResult: { requiresRestart: boolean } | null = null;
|
|
await act(async () => {
|
|
saveResult = await result.current.saveSettings();
|
|
});
|
|
|
|
expect(saveResult).toEqual({ requiresRestart: false });
|
|
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith(null);
|
|
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
|
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
|
// 目录未变化,不应触发同步
|
|
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows toast when Claude plugin sync fails but continues flow", async () => {
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: {
|
|
...serverSettings,
|
|
enableClaudePluginIntegration: true,
|
|
language: "zh",
|
|
},
|
|
});
|
|
directorySettingsMock = createDirectorySettingsMock({
|
|
appConfigDir: "/override/app",
|
|
initialAppConfigDir: "/prior/app",
|
|
});
|
|
|
|
applyClaudePluginConfigMock.mockRejectedValueOnce(new Error("sync failed"));
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await act(async () => {
|
|
await result.current.saveSettings();
|
|
});
|
|
|
|
expect(toastErrorMock).toHaveBeenCalled();
|
|
const message = toastErrorMock.mock.calls.at(-1)?.[0] as string;
|
|
expect(message).toContain("同步 Claude 插件失败");
|
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
|
|
});
|
|
|
|
it("resets form, language and directories using server data", () => {
|
|
serverSettings = {
|
|
...serverSettings,
|
|
claudeConfigDir: " /server/claude ",
|
|
codexConfigDir: " ",
|
|
language: "zh",
|
|
};
|
|
useSettingsQueryMock.mockReturnValue({
|
|
data: serverSettings,
|
|
isLoading: false,
|
|
});
|
|
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: {
|
|
...serverSettings,
|
|
language: "zh",
|
|
},
|
|
initialLanguage: "zh",
|
|
});
|
|
directorySettingsMock = createDirectorySettingsMock();
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
act(() => {
|
|
result.current.resetSettings();
|
|
});
|
|
|
|
expect(settingsFormMock.resetSettings).toHaveBeenCalledWith(serverSettings);
|
|
expect(settingsFormMock.syncLanguage).toHaveBeenCalledWith(
|
|
settingsFormMock.initialLanguage,
|
|
);
|
|
expect(directorySettingsMock.resetAllDirectories).toHaveBeenCalledWith(
|
|
"/server/claude",
|
|
undefined,
|
|
);
|
|
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
|
|
});
|
|
|
|
it("returns null immediately when settings state is missing", async () => {
|
|
settingsFormMock = createSettingsFormMock({
|
|
settings: null,
|
|
});
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
let resultValue: { requiresRestart: boolean } | null = null;
|
|
await act(async () => {
|
|
resultValue = await result.current.saveSettings();
|
|
});
|
|
|
|
expect(resultValue).toBeNull();
|
|
expect(mutateAsyncMock).not.toHaveBeenCalled();
|
|
expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when save mutation rejects and keeps restart flag untouched", async () => {
|
|
settingsFormMock = createSettingsFormMock();
|
|
directorySettingsMock = createDirectorySettingsMock({
|
|
appConfigDir: "/override/app",
|
|
initialAppConfigDir: "/override/app",
|
|
});
|
|
const rejection = new Error("save failed");
|
|
mutateAsyncMock.mockRejectedValueOnce(rejection);
|
|
|
|
const { result } = renderHook(() => useSettings());
|
|
|
|
await expect(
|
|
act(async () => {
|
|
await result.current.saveSettings();
|
|
}),
|
|
).rejects.toThrow("save failed");
|
|
|
|
expect(setAppConfigDirOverrideMock).not.toHaveBeenCalled();
|
|
expect(metadataMock.setRequiresRestart).not.toHaveBeenCalledWith(true);
|
|
});
|
|
});
|