mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-03-26 07:36:46 +08:00
* feat(db): add circuit breaker config table and provider proxy target APIs Add database support for auto-failover feature: - Add circuit_breaker_config table for storing failover thresholds - Add get/update_circuit_breaker_config methods in proxy DAO - Add reset_provider_health method for manual recovery - Add set_proxy_target and get_proxy_targets methods in providers DAO for managing multi-provider failover configuration * feat(proxy): implement circuit breaker and provider router for auto-failover Add core failover logic: - CircuitBreaker: Tracks provider health with three states: - Closed: Normal operation, requests pass through - Open: Circuit broken after consecutive failures, skip provider - HalfOpen: Testing recovery with limited requests - ProviderRouter: Routes requests across multiple providers with: - Health tracking and automatic failover - Configurable failure/success thresholds - Auto-disable proxy target after reaching failure threshold - Support for manual circuit breaker reset - Export new types in proxy module * feat(proxy): add failover Tauri commands and integrate with forwarder Expose failover functionality to frontend: - Add Tauri commands: get_proxy_targets, set_proxy_target, get_provider_health, reset_circuit_breaker, get/update_circuit_breaker_config, get_circuit_breaker_stats - Register all new commands in lib.rs invoke handler - Update forwarder with improved error handling and logging - Integrate ProviderRouter with proxy server startup - Add provider health tracking in request handlers * feat(frontend): add failover API layer and TanStack Query hooks Add frontend data layer for failover management: - Add failover.ts API: Tauri invoke wrappers for all failover commands - Add failover.ts query hooks: TanStack Query mutations and queries - useProxyTargets, useProviderHealth queries - useSetProxyTarget, useResetCircuitBreaker mutations - useCircuitBreakerConfig query and mutation - Update queries.ts with provider health query key - Update mutations.ts to invalidate health on provider changes - Add CircuitBreakerConfig and ProviderHealth types * feat(ui): add auto-failover configuration UI and provider health display Add comprehensive UI for failover management: Components: - ProviderHealthBadge: Display provider health status with color coding - CircuitBreakerConfigPanel: Configure failure/success thresholds, timeout duration, and error rate limits - AutoFailoverConfigPanel: Manage proxy targets with drag-and-drop priority ordering and individual enable/disable controls - ProxyPanel: Integrate failover tabs for unified proxy management Provider enhancements: - ProviderCard: Show health badge and proxy target indicator - ProviderActions: Add "Set as Proxy Target" action - EditProviderDialog: Add is_proxy_target toggle - ProviderList: Support proxy target filtering mode Other: - Update App.tsx routing for settings integration - Update useProviderActions hook with proxy target mutation - Fix ProviderList tests for updated component API * fix(usage): stabilize date range to prevent infinite re-renders * feat(backend): add tool version check command Add get_tool_versions command to check local and latest versions of Claude, Codex, and Gemini CLI tools: - Detect local installed versions via command line execution - Fetch latest versions from npm registry (Claude, Gemini) and GitHub releases API (Codex) - Return comprehensive version info including error details for uninstalled tools - Register command in Tauri invoke handler * style(ui): format accordion component code style Apply consistent code formatting to accordion component: - Convert double quotes to semicolons at line endings - Adjust indentation to 2-space standard - Align with project code style conventions * refactor(providers): update provider card styling to use theme tokens Replace hardcoded color classes with semantic design tokens: - Use bg-card, border-border, text-card-foreground instead of glass-card - Replace gray/white color literals with muted/foreground tokens - Change proxy target indicator color from purple to green - Improve hover states with border-border-active - Ensure consistent dark mode support via CSS variables * refactor(proxy): simplify auto-failover config panel structure Restructure AutoFailoverConfigPanel for better integration: - Remove internal Card wrapper and expansion toggle (now handled by parent) - Extract enabled state to props for external control - Simplify loading state display - Clean up redundant CardHeader/CardContent wrappers - ProxyPanel: reduce complexity by delegating to parent components * feat(settings): enhance settings page with accordion layout and tool versions Major settings page improvements: AboutSection: - Add local tool version detection (Claude, Codex, Gemini) - Display installed vs latest version comparison with visual indicators - Show update availability badges and environment check cards SettingsPage: - Reorganize advanced settings into collapsible accordion sections - Add proxy control panel with inline status toggle - Integrate auto-failover configuration with accordion UI - Add database and cost calculation config sections DirectorySettings & WindowSettings: - Minor styling adjustments for consistency settings.ts API: - Add getToolVersions() wrapper for new backend command * refactor(usage): restructure usage dashboard components Comprehensive usage statistics panel refactoring: UsageDashboard: - Reorganize layout with improved section headers - Add better loading states and empty state handling ModelStatsTable & ProviderStatsTable: - Minor styling updates for consistency ModelTestConfigPanel & PricingConfigPanel: - Simplify component structure - Remove redundant Card wrappers - Improve form field organization RequestLogTable: - Enhance table layout with better column sizing - Improve pagination controls UsageSummaryCards: - Update card styling with semantic tokens - Better responsive grid layout UsageTrendChart: - Refine chart container styling - Improve legend and tooltip display * chore(deps): add accordion and animation dependencies Package updates: - Add @radix-ui/react-accordion for collapsible sections - Add cmdk for command palette support - Add framer-motion for enhanced animations Tailwind config: - Add accordion-up/accordion-down animations - Update darkMode config to support both selector and class - Reorganize color and keyframe definitions for clarity * style(app): update header and app switcher styling App.tsx: - Replace glass-header with explicit bg-background/80 backdrop-blur - Update navigation button container to use bg-muted AppSwitcher: - Replace hardcoded gray colors with semantic muted/foreground tokens - Ensure consistent dark mode support via CSS variables - Add group class for better hover state transitions
239 lines
6.6 KiB
TypeScript
239 lines
6.6 KiB
TypeScript
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import type { Provider } from "@/types";
|
|
import { ProviderList } from "@/components/providers/ProviderList";
|
|
|
|
const useDragSortMock = vi.fn();
|
|
const useSortableMock = vi.fn();
|
|
const providerCardRenderSpy = vi.fn();
|
|
|
|
vi.mock("@/hooks/useDragSort", () => ({
|
|
useDragSort: (...args: unknown[]) => useDragSortMock(...args),
|
|
}));
|
|
|
|
vi.mock("@/components/providers/ProviderCard", () => ({
|
|
ProviderCard: (props: any) => {
|
|
providerCardRenderSpy(props);
|
|
const {
|
|
provider,
|
|
onSwitch,
|
|
onEdit,
|
|
onDelete,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
} = props;
|
|
|
|
return (
|
|
<div data-testid={`provider-card-${provider.id}`}>
|
|
<button
|
|
data-testid={`switch-${provider.id}`}
|
|
onClick={() => onSwitch(provider)}
|
|
>
|
|
switch
|
|
</button>
|
|
<button
|
|
data-testid={`edit-${provider.id}`}
|
|
onClick={() => onEdit(provider)}
|
|
>
|
|
edit
|
|
</button>
|
|
<button
|
|
data-testid={`duplicate-${provider.id}`}
|
|
onClick={() => onDuplicate(provider)}
|
|
>
|
|
duplicate
|
|
</button>
|
|
<button
|
|
data-testid={`usage-${provider.id}`}
|
|
onClick={() => onConfigureUsage(provider)}
|
|
>
|
|
usage
|
|
</button>
|
|
<button
|
|
data-testid={`delete-${provider.id}`}
|
|
onClick={() => onDelete(provider)}
|
|
>
|
|
delete
|
|
</button>
|
|
<span data-testid={`is-current-${provider.id}`}>
|
|
{props.isCurrent ? "current" : "inactive"}
|
|
</span>
|
|
<span data-testid={`drag-attr-${provider.id}`}>
|
|
{props.dragHandleProps?.attributes?.["data-dnd-id"] ?? "none"}
|
|
</span>
|
|
</div>
|
|
);
|
|
},
|
|
}));
|
|
|
|
vi.mock("@/components/UsageFooter", () => ({
|
|
default: () => <div data-testid="usage-footer" />,
|
|
}));
|
|
|
|
vi.mock("@dnd-kit/sortable", async () => {
|
|
const actual = await vi.importActual<any>("@dnd-kit/sortable");
|
|
|
|
return {
|
|
...actual,
|
|
useSortable: (...args: unknown[]) => useSortableMock(...args),
|
|
};
|
|
});
|
|
|
|
function createProvider(overrides: Partial<Provider> = {}): Provider {
|
|
return {
|
|
id: overrides.id ?? "provider-1",
|
|
name: overrides.name ?? "Test Provider",
|
|
settingsConfig: overrides.settingsConfig ?? {},
|
|
category: overrides.category,
|
|
createdAt: overrides.createdAt,
|
|
sortIndex: overrides.sortIndex,
|
|
meta: overrides.meta,
|
|
websiteUrl: overrides.websiteUrl,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
useDragSortMock.mockReset();
|
|
useSortableMock.mockReset();
|
|
providerCardRenderSpy.mockClear();
|
|
|
|
useSortableMock.mockImplementation(({ id }: { id: string }) => ({
|
|
setNodeRef: vi.fn(),
|
|
attributes: { "data-dnd-id": id },
|
|
listeners: { onPointerDown: vi.fn() },
|
|
transform: null,
|
|
transition: null,
|
|
isDragging: false,
|
|
}));
|
|
|
|
useDragSortMock.mockReturnValue({
|
|
sortedProviders: [],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
});
|
|
|
|
describe("ProviderList Component", () => {
|
|
it("should render skeleton placeholders when loading", () => {
|
|
const { container } = render(
|
|
<ProviderList
|
|
providers={{}}
|
|
currentProviderId=""
|
|
appId="claude"
|
|
onSwitch={vi.fn()}
|
|
onEdit={vi.fn()}
|
|
onDelete={vi.fn()}
|
|
onDuplicate={vi.fn()}
|
|
onOpenWebsite={vi.fn()}
|
|
isLoading
|
|
/>,
|
|
);
|
|
|
|
const placeholders = container.querySelectorAll(
|
|
".border-dashed.border-muted-foreground\\/40",
|
|
);
|
|
expect(placeholders).toHaveLength(3);
|
|
});
|
|
|
|
it("should show empty state and trigger create callback when no providers exist", () => {
|
|
const handleCreate = vi.fn();
|
|
useDragSortMock.mockReturnValueOnce({
|
|
sortedProviders: [],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ProviderList
|
|
providers={{}}
|
|
currentProviderId=""
|
|
appId="claude"
|
|
onSwitch={vi.fn()}
|
|
onEdit={vi.fn()}
|
|
onDelete={vi.fn()}
|
|
onDuplicate={vi.fn()}
|
|
onOpenWebsite={vi.fn()}
|
|
onCreate={handleCreate}
|
|
/>,
|
|
);
|
|
|
|
const addButton = screen.getByRole("button", {
|
|
name: "provider.addProvider",
|
|
});
|
|
fireEvent.click(addButton);
|
|
|
|
expect(handleCreate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("should render in order returned by useDragSort and pass through action callbacks", () => {
|
|
const providerA = createProvider({ id: "a", name: "A" });
|
|
const providerB = createProvider({ id: "b", name: "B" });
|
|
|
|
const handleSwitch = vi.fn();
|
|
const handleEdit = vi.fn();
|
|
const handleDelete = vi.fn();
|
|
const handleDuplicate = vi.fn();
|
|
const handleUsage = vi.fn();
|
|
const handleOpenWebsite = vi.fn();
|
|
|
|
useDragSortMock.mockReturnValue({
|
|
sortedProviders: [providerB, providerA],
|
|
sensors: [],
|
|
handleDragEnd: vi.fn(),
|
|
});
|
|
|
|
render(
|
|
<ProviderList
|
|
providers={{ a: providerA, b: providerB }}
|
|
currentProviderId="b"
|
|
appId="claude"
|
|
onSwitch={handleSwitch}
|
|
onEdit={handleEdit}
|
|
onDelete={handleDelete}
|
|
onDuplicate={handleDuplicate}
|
|
onConfigureUsage={handleUsage}
|
|
onOpenWebsite={handleOpenWebsite}
|
|
/>,
|
|
);
|
|
|
|
// Verify sort order
|
|
expect(providerCardRenderSpy).toHaveBeenCalledTimes(2);
|
|
expect(providerCardRenderSpy.mock.calls[0][0].provider.id).toBe("b");
|
|
expect(providerCardRenderSpy.mock.calls[1][0].provider.id).toBe("a");
|
|
|
|
// Verify current provider marker
|
|
expect(providerCardRenderSpy.mock.calls[0][0].isCurrent).toBe(true);
|
|
|
|
// Drag attributes from useSortable
|
|
expect(
|
|
providerCardRenderSpy.mock.calls[0][0].dragHandleProps?.attributes[
|
|
"data-dnd-id"
|
|
],
|
|
).toBe("b");
|
|
expect(
|
|
providerCardRenderSpy.mock.calls[1][0].dragHandleProps?.attributes[
|
|
"data-dnd-id"
|
|
],
|
|
).toBe("a");
|
|
|
|
// Trigger action buttons
|
|
fireEvent.click(screen.getByTestId("switch-b"));
|
|
fireEvent.click(screen.getByTestId("edit-b"));
|
|
fireEvent.click(screen.getByTestId("duplicate-b"));
|
|
fireEvent.click(screen.getByTestId("usage-b"));
|
|
fireEvent.click(screen.getByTestId("delete-a"));
|
|
|
|
expect(handleSwitch).toHaveBeenCalledWith(providerB);
|
|
expect(handleEdit).toHaveBeenCalledWith(providerB);
|
|
expect(handleDuplicate).toHaveBeenCalledWith(providerB);
|
|
expect(handleUsage).toHaveBeenCalledWith(providerB);
|
|
expect(handleDelete).toHaveBeenCalledWith(providerA);
|
|
|
|
// Verify useDragSort call parameters
|
|
expect(useDragSortMock).toHaveBeenCalledWith(
|
|
{ a: providerA, b: providerB },
|
|
"claude",
|
|
);
|
|
});
|
|
});
|