Files
wehub-resource-sync 64ed910d3f
CI / Backend Checks (push) Has been cancelled
CI / Frontend Checks (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 11:57:20 +08:00

507 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 applyClaudeOnboardingSkipMock = vi.fn();
const clearClaudeOnboardingSkipMock = vi.fn();
const syncCurrentProvidersLiveMock = vi.fn();
const updateTrayMenuMock = vi.fn();
const getCurrentMock = vi.fn();
const getAllMock = vi.fn();
const getQueryDataMock = 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("@tanstack/react-query", async () => {
const actual = await vi.importActual<typeof import("@tanstack/react-query")>(
"@tanstack/react-query",
);
return {
...actual,
useQueryClient: () => ({
getQueryData: (...args: unknown[]) => getQueryDataMock(...args),
}),
};
});
vi.mock("@/lib/api", () => ({
settingsApi: {
setAppConfigDirOverride: (...args: unknown[]) =>
setAppConfigDirOverrideMock(...args),
applyClaudePluginConfig: (...args: unknown[]) =>
applyClaudePluginConfigMock(...args),
applyClaudeOnboardingSkip: (...args: unknown[]) =>
applyClaudeOnboardingSkipMock(...args),
clearClaudeOnboardingSkip: (...args: unknown[]) =>
clearClaudeOnboardingSkipMock(...args),
syncCurrentProvidersLive: (...args: unknown[]) =>
syncCurrentProvidersLiveMock(...args),
},
providersApi: {
updateTrayMenu: (...args: unknown[]) => updateTrayMenuMock(...args),
getCurrent: (...args: unknown[]) => getCurrentMock(...args),
getAll: (...args: unknown[]) => getAllMock(...args),
},
}));
const createSettingsFormMock = (overrides: Record<string, unknown> = {}) => ({
settings: {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: true,
claudeConfigDir: "/claude",
codexConfigDir: "/codex",
geminiConfigDir: "/gemini",
opencodeConfigDir: "/opencode",
openclawConfigDir: "/openclaw",
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",
gemini: "/default/gemini",
opencode: "/default/opencode",
openclaw: "/default/openclaw",
},
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();
applyClaudeOnboardingSkipMock.mockReset();
clearClaudeOnboardingSkipMock.mockReset();
syncCurrentProvidersLiveMock.mockReset();
getCurrentMock.mockReset();
getAllMock.mockReset();
getQueryDataMock.mockReset();
toastErrorMock.mockReset();
toastSuccessMock.mockReset();
window.localStorage.clear();
serverSettings = {
showInTray: true,
minimizeToTrayOnClose: true,
enableClaudePluginIntegration: false,
skipClaudeOnboarding: true,
claudeConfigDir: "/server/claude",
codexConfigDir: "/server/codex",
geminiConfigDir: "/server/gemini",
opencodeConfigDir: "/server/opencode",
openclawConfigDir: "/server/openclaw",
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);
applyClaudeOnboardingSkipMock.mockResolvedValue(true);
clearClaudeOnboardingSkipMock.mockResolvedValue(true);
syncCurrentProvidersLiveMock.mockResolvedValue({ ok: true });
getCurrentMock.mockResolvedValue(null);
getAllMock.mockResolvedValue({});
// 默认将 queryClient 缓存对齐到 serverSettings,既有断言的 "prev === data" 语义保持不变
getQueryDataMock.mockImplementation(() => serverSettings);
});
it("auto-saves and applies Claude onboarding skip when toggled on", async () => {
serverSettings = {
...serverSettings,
skipClaudeOnboarding: false,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
skipClaudeOnboarding: false,
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.autoSaveSettings({ skipClaudeOnboarding: true });
});
expect(applyClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("auto-saves and clears Claude onboarding skip when toggled off", async () => {
serverSettings = {
...serverSettings,
skipClaudeOnboarding: true,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
language: "zh",
skipClaudeOnboarding: true,
},
});
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.autoSaveSettings({ skipClaudeOnboarding: false });
});
expect(clearClaudeOnboardingSkipMock).toHaveBeenCalledTimes(1);
expect(toastErrorMock).not.toHaveBeenCalled();
});
it("saves settings and flags restart when app config directory changes", async () => {
serverSettings = {
...serverSettings,
enableClaudePluginIntegration: false,
claudeConfigDir: "/server/claude",
codexConfigDir: undefined,
geminiConfigDir: "/server/gemini",
opencodeConfigDir: "/server/opencode",
openclawConfigDir: "/server/openclaw",
language: "en",
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
claudeConfigDir: " /custom/claude ",
codexConfigDir: " ",
openclawConfigDir: " /custom/openclaw ",
language: "en",
enableClaudePluginIntegration: true, // 状态从 false 变为 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.openclawConfigDir).toBe("/custom/openclaw");
expect(payload.language).toBe("en");
expect(setAppConfigDirOverrideMock).toHaveBeenCalledWith("/override/app");
// 状态改变,应该调用 API
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({
official: false,
});
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(true);
expect(window.localStorage.getItem("language")).toBe("en");
expect(toastErrorMock).not.toHaveBeenCalled();
// 插件同步已包含 syncCurrentProvidersLiveSafe,目录变更不再重复调用
expect(syncCurrentProvidersLiveMock).toHaveBeenCalledTimes(1);
});
it("saves settings without restart when directory unchanged", async () => {
// 确保服务器和本地状态一致,不触发 API 调用
serverSettings = {
...serverSettings,
enableClaudePluginIntegration: false,
launchOnStartup: false,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
enableClaudePluginIntegration: false, // 状态未变
launchOnStartup: 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);
// 状态未改变,不应调用 API
expect(applyClaudePluginConfigMock).not.toHaveBeenCalled();
expect(metadataMock.setRequiresRestart).toHaveBeenCalledWith(false);
// 目录未变化,不应触发同步
expect(syncCurrentProvidersLiveMock).not.toHaveBeenCalled();
});
it("shows toast when Claude plugin sync fails but continues flow", async () => {
// 设置服务器状态为 false,本地状态为 true,触发状态变化
serverSettings = {
...serverSettings,
enableClaudePluginIntegration: false,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
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("detects plugin toggle via live cache even when closure data is stale", async () => {
// 模拟快速连切后的 raceuseSettingsQueryMock 的 data 滞后停留在 falseclosure 未更新),
// 但 queryClient 缓存(getQueryData)实时值已为 true(上次持久化到 enabled),
// form 里用户想切回 false。旧实现会因 data === form 而跳过副作用;新实现应读 prev=true 并执行。
serverSettings = {
...serverSettings,
enableClaudePluginIntegration: false,
};
useSettingsQueryMock.mockReturnValue({
data: serverSettings,
isLoading: false,
});
settingsFormMock = createSettingsFormMock({
settings: {
...serverSettings,
enableClaudePluginIntegration: false,
language: "zh",
},
});
directorySettingsMock = createDirectorySettingsMock();
// 缓存里的"真实上次值"是 trueenabled),与 closure data(false) 有时序差
getQueryDataMock.mockImplementation(() => ({
...serverSettings,
enableClaudePluginIntegration: true,
}));
const { result } = renderHook(() => useSettings());
await act(async () => {
await result.current.saveSettings(undefined, { silent: true });
});
// 修复生效:读的是缓存实时值 true,payload=false,差异触发 clear_claude_config
expect(applyClaudePluginConfigMock).toHaveBeenCalledWith({ official: true });
expect(syncCurrentProvidersLiveMock).toHaveBeenCalled();
});
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({
claude: "/server/claude",
codex: undefined,
gemini: "/server/gemini",
opencode: "/server/opencode",
openclaw: "/server/openclaw",
hermes: 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);
});
});