mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-04-08 15:10:34 +08:00
* feat(provider): support additive provider key lifecycle management
Add `addToLive` parameter to add_provider so callers can opt out of
writing to the live config (e.g. when duplicating an inactive provider).
Add `originalId` parameter to update_provider to support provider key
renames — the old key is removed from live config before the new one
is written.
Frontend: ProviderForm now exposes provider-key input for openclaw app
type, and EditProviderDialog forwards originalId on save. Deep-link
import passes addToLive=true to preserve existing behavior.
* test(provider): add integration tests for additive provider key flows
Cover openclaw provider duplication scenario to verify that a generated
provider key is assigned automatically. Add MSW handlers for
get_openclaw_live_provider_ids, get_openclaw_default_model,
scan_openclaw_config_health, and check_env_conflicts endpoints.
Update EditProviderDialog mock to pass originalId alongside provider.
* fix(openclaw): replace json-five serializer to prevent panic on empty collections
json-five 0.3.1 panics when pretty-printing nested empty maps/arrays.
Switch value_to_rt_value() to serde_json::to_string_pretty() which
produces valid JSON5 output without the panic. Add regression test for
removing the last provider (empty providers map).
* style: apply rustfmt formatting to proxy and provider modules
Reformat chained .header() calls in ClaudeAdapter and StreamCheckService
for consistent alignment. Reorder imports alphabetically in stream_check.
Fix trailing whitespace in transform.rs and merge import lines in
provider/mod.rs.
* style: fix clippy warnings in live.rs and tray.rs
* refactor(provider): simplify live_config_managed and deduplicate tolerant live config checks
- Change live_config_managed from Option<bool> to bool with #[serde(default)]
- Extract repeated tolerant live config query into check_live_config_exists helper
- Fix duplicate key generation to also check live-only provider IDs
- Fix updateProvider test to match new { provider, originalId } call signature
- Add streaming_responses test type annotation for compiler inference
* fix(provider): distinguish legacy providers from db-only when tolerating live config errors
Change `ProviderMeta.live_config_managed` from `bool` to `Option<bool>`
to introduce a three-state semantic:
- `Some(true)`: provider has been written to live config
- `Some(false)`: explicitly db-only, never written to live config
- `None`: legacy data or unknown state (pre-existing providers)
Previously, legacy providers defaulted to `live_config_managed = false`
via `#[serde(default)]`, which silently swallowed live config parse
errors. This could mask genuine configuration issues for providers that
had actually been synced to live config before the field was introduced.
Now, only providers with an explicit `Some(false)` marker tolerate parse
errors; legacy `None` providers surface errors as before, preserving
safety for already-managed configurations.
Also wrap the `ensureQueryData` call for live provider IDs during
duplication in a try/catch so that a malformed config file shows a
user-facing toast instead of silently failing.
Add tests for both the legacy error propagation path and the frontend
duplication failure scenario.
* refactor(provider): unify OMO variant updates with atomic file-then-db writes and rollback
Consolidate the duplicated omo/omo-slim update branches into a single
match on the variant. Write the OMO config file from the in-memory
provider state *before* persisting to the database, so a file-write or
plugin-sync failure leaves the database unchanged. If `add_plugin`
fails after the config file is already written, roll back to the
previous on-disk contents via snapshot/restore.
Also:
- `sync_all_providers_to_live` now skips db-only providers
(`live_config_managed == Some(false)`) instead of attempting to write
them to live config.
- `import_{opencode,openclaw}_providers_from_live` mark imported
providers as `live_config_managed: Some(true)` so they are correctly
recognized during subsequent syncs.
- Extract OmoService helpers: `profile_data_from_provider`,
`snapshot_config_file`, `restore_config_file`, `write_profile_config`,
and the new public `write_provider_config_to_file`.
- Add 9 new tests covering sync skip, legacy restore, import marking,
OMO persistence, file-write failure, and plugin-sync rollback.
* fix(provider): fix additive provider delete/switch regressions and redundancy
- fix(delete): replace stale live_config_managed flag check with
check_live_config_exists so providers written to live before the
flag-flip logic was introduced are still cleaned up on delete
- fix(switch): make write_live_with_common_config return Err instead of
silently returning Ok when config structure is invalid, preventing
live_config_managed from being incorrectly flipped to true
- fix(update): block provider key rename for OMO/OMO Slim categories to
prevent orphaned current-state markers breaking OMO file syncs
- fix(switch): flip live_config_managed to true after successful live
write for DB-only additive providers so sync_all_providers_to_live
includes them on future syncs; roll back live write if DB update fails
- refactor(delete): merge symmetric OMO/OMO-Slim blocks into single
match-on-variant path; hoist DB read to top of additive branch
- refactor(remove_from_live_config): merge OMO/OMO-Slim if/else-if
into single match-on-variant path
- refactor(switch_normal): merge two OMO/OMO-Slim if blocks into one
OpenCode guard with (enable, disable) variant pair
- fix(update): remove redundant duplicate return Ok(true) after OMO
current-state write
* fix(test): use preferred_filename after OMO field rename
The merge from main brought in #1746 which renamed
OmoVariant.filename → preferred_filename, but the test helper
omo_config_path() was not updated, breaking compilation of all
new provider tests.
---------
Co-authored-by: Jason <farion1231@gmail.com>
563 lines
17 KiB
TypeScript
563 lines
17 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();
|
|
const toastInfoMock = vi.fn();
|
|
const toastWarningMock = vi.fn();
|
|
|
|
vi.mock("sonner", () => ({
|
|
toast: {
|
|
success: (...args: unknown[]) => toastSuccessMock(...args),
|
|
error: (...args: unknown[]) => toastErrorMock(...args),
|
|
info: (...args: unknown[]) => toastInfoMock(...args),
|
|
warning: (...args: unknown[]) => toastWarningMock(...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();
|
|
const openclawApiGetModelCatalogMock = vi.fn();
|
|
const openclawApiGetDefaultModelMock = vi.fn();
|
|
const openclawApiSetDefaultModelMock = 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),
|
|
},
|
|
openclawApi: {
|
|
getModelCatalog: (...args: unknown[]) =>
|
|
openclawApiGetModelCatalogMock(...args),
|
|
getDefaultModel: (...args: unknown[]) =>
|
|
openclawApiGetDefaultModelMock(...args),
|
|
setDefaultModel: (...args: unknown[]) =>
|
|
openclawApiSetDefaultModelMock(...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();
|
|
openclawApiGetModelCatalogMock.mockReset();
|
|
openclawApiGetDefaultModelMock.mockReset();
|
|
openclawApiSetDefaultModelMock.mockReset();
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
toastInfoMock.mockReset();
|
|
toastWarningMock.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,
|
|
originalId: undefined,
|
|
});
|
|
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("blocks switching providers that require proxy when proxy is not running", async () => {
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
category: "custom",
|
|
meta: {
|
|
apiFormat: "openai_chat",
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("claude", false), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
|
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiGetMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("blocks switching Codex full URL providers when proxy is not running", async () => {
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
category: "custom",
|
|
meta: {
|
|
isFullUrl: true,
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("codex", false), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.switchProvider(provider);
|
|
});
|
|
|
|
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
|
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
|
expect(settingsApiGetMock).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("does not show backup details when setting OpenClaw default model", async () => {
|
|
openclawApiSetDefaultModelMock.mockResolvedValueOnce({
|
|
backupPath: "/tmp/openclaw-backup.json5",
|
|
warnings: [],
|
|
});
|
|
|
|
const { wrapper } = createWrapper();
|
|
const provider = createProvider({
|
|
settingsConfig: {
|
|
models: [{ id: "gpt-4.1" }, { id: "gpt-4.1-mini" }],
|
|
},
|
|
});
|
|
|
|
const { result } = renderHook(() => useProviderActions("openclaw"), {
|
|
wrapper,
|
|
});
|
|
|
|
await act(async () => {
|
|
await result.current.setAsDefaultModel(provider);
|
|
});
|
|
|
|
expect(openclawApiSetDefaultModelMock).toHaveBeenCalledWith({
|
|
primary: "provider-1/gpt-4.1",
|
|
fallbacks: ["provider-1/gpt-4.1-mini"],
|
|
});
|
|
expect(toastSuccessMock).toHaveBeenCalledTimes(1);
|
|
expect(toastSuccessMock.mock.calls[0]?.[1]).toEqual({ closeButton: 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);
|
|
});
|