mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
2a24da517f
* Add S3 Cloud Sync design document Design for adding AWS S3 as a new Cloud Sync backend alongside WebDAV. Hybrid approach: extract shared sync protocol, add independent S3 transport. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation design (reqwest + Sig V4) Updated design based on 2026-03-06 draft: switches from rust-s3 crate to hand-rolled AWS Sig V4 on existing reqwest for broader S3-compatible service support (AWS, MinIO, R2, Alibaba OSS, Tencent COS, Huawei OBS). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add S3 cloud sync implementation plan (11 tasks, TDD) Detailed step-by-step plan covering: sync_protocol extraction, S3 Sig V4 transport, settings, sync/auto-sync modules, Tauri commands, frontend presets/dynamic form, and i18n. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * deps: add hmac crate for S3 Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: extract sync_protocol.rs from webdav_sync.rs for shared use Move transport-agnostic sync protocol logic (constants, types, snapshot building, manifest validation, artifact verification, snapshot application, utilities) into a new shared sync_protocol module so both WebDAV and the upcoming S3 transport can reuse it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: use transport-neutral error keys in sync_protocol Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 transport layer with AWS Sig V4 signing Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3SyncSettings to AppSettings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync module with upload/download/fetch Implements the S3 sync protocol layer (s3_sync.rs) that combines the shared sync_protocol with the S3 transport. Mirrors the WebDAV sync module structure with independent sync mutex, connection check, upload, download, fetch_remote_info, and sync status persistence. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 auto sync worker with debounce Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync Tauri commands and auto sync worker startup Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync TypeScript types and API layer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync i18n translations (en/zh/ja) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add S3 sync presets and dynamic form to sync settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: preserve HTTP scheme for S3 custom endpoints (MinIO support) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * test: add live S3 integration tests (env-var driven, --ignored) Run with: S3_TEST_AK=... S3_TEST_SK=... S3_TEST_BUCKET=... cargo test --lib services::s3::integration_tests -- --ignored Verifies test_connection, put_object, get_object, head_object, and 404 handling against a real S3 bucket using the project's own Sig V4 signing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: remove internal design docs before PR * fix: wire S3 auto-sync to DB hook & sync UI state on async load - P1: Add s3_auto_sync::notify_db_changed call in SQLite update_hook so S3 auto-sync worker receives DB change signals (was only wired for WebDAV, leaving S3 worker idle) - P2: Add useEffect to update syncType selector when s3Config loads asynchronously, preventing stale "webdav" default for S3 users * fix: satisfy clippy for s3 sync * fix: address s3 sync review feedback --------- Co-authored-by: Keith (via OpenClaw) <keithyt06@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jason <farion1231@gmail.com>
356 lines
10 KiB
TypeScript
356 lines
10 KiB
TypeScript
import { Suspense, type ComponentType } from "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 { providersApi } from "@/lib/api/providers";
|
|
import {
|
|
resetProviderState,
|
|
setCurrentProviderId,
|
|
setLiveProviderIds,
|
|
setProviders,
|
|
} from "../msw/state";
|
|
import { emitTauriEvent } from "../msw/tauriMocks";
|
|
|
|
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/providers/ProviderList", () => ({
|
|
ProviderList: ({
|
|
providers,
|
|
currentProviderId,
|
|
onSwitch,
|
|
onEdit,
|
|
onDuplicate,
|
|
onConfigureUsage,
|
|
onOpenWebsite,
|
|
onCreate,
|
|
}: any) => (
|
|
<div>
|
|
<div data-testid="provider-list">{JSON.stringify(providers)}</div>
|
|
<div data-testid="current-provider">{currentProviderId}</div>
|
|
<button onClick={() => onSwitch(providers[currentProviderId])}>
|
|
switch
|
|
</button>
|
|
<button onClick={() => onEdit(providers[currentProviderId])}>edit</button>
|
|
<button onClick={() => onDuplicate(providers[currentProviderId])}>
|
|
duplicate
|
|
</button>
|
|
<button onClick={() => onConfigureUsage(providers[currentProviderId])}>
|
|
usage
|
|
</button>
|
|
<button onClick={() => onOpenWebsite("https://example.com")}>
|
|
open-website
|
|
</button>
|
|
<button onClick={() => onCreate?.()}>create</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/providers/AddProviderDialog", () => ({
|
|
AddProviderDialog: ({ open, onOpenChange, onSubmit, appId }: any) =>
|
|
open ? (
|
|
<div data-testid="add-provider-dialog">
|
|
<button
|
|
onClick={() =>
|
|
onSubmit({
|
|
name: `New ${appId} Provider`,
|
|
settingsConfig: {},
|
|
category: "custom",
|
|
sortIndex: 99,
|
|
})
|
|
}
|
|
>
|
|
confirm-add
|
|
</button>
|
|
<button onClick={() => onOpenChange(false)}>close-add</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/providers/EditProviderDialog", () => ({
|
|
EditProviderDialog: ({ open, provider, onSubmit, onOpenChange }: any) =>
|
|
open ? (
|
|
<div data-testid="edit-provider-dialog">
|
|
<button
|
|
onClick={() =>
|
|
onSubmit({
|
|
provider: {
|
|
...provider,
|
|
name: `${provider.name}-edited`,
|
|
},
|
|
originalId: provider.id,
|
|
})
|
|
}
|
|
>
|
|
confirm-edit
|
|
</button>
|
|
<button onClick={() => onOpenChange(false)}>close-edit</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/UsageScriptModal", () => ({
|
|
default: ({ isOpen, provider, onSave, onClose }: any) =>
|
|
isOpen ? (
|
|
<div data-testid="usage-modal">
|
|
<span data-testid="usage-provider">{provider?.id}</span>
|
|
<button onClick={() => onSave("script-code")}>save-script</button>
|
|
<button onClick={() => onClose()}>close-usage</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/ConfirmDialog", () => ({
|
|
ConfirmDialog: ({ isOpen, onConfirm, onCancel }: any) =>
|
|
isOpen ? (
|
|
<div data-testid="confirm-dialog">
|
|
<button onClick={() => onConfirm()}>confirm-delete</button>
|
|
<button onClick={() => onCancel()}>cancel-delete</button>
|
|
</div>
|
|
) : null,
|
|
}));
|
|
|
|
vi.mock("@/components/AppSwitcher", () => ({
|
|
AppSwitcher: ({ activeApp, onSwitch }: any) => (
|
|
<div data-testid="app-switcher">
|
|
<span>{activeApp}</span>
|
|
<button onClick={() => onSwitch("claude")}>switch-claude</button>
|
|
<button onClick={() => onSwitch("codex")}>switch-codex</button>
|
|
<button onClick={() => onSwitch("openclaw")}>switch-openclaw</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/UpdateBadge", () => ({
|
|
UpdateBadge: ({ onClick }: any) => (
|
|
<button onClick={onClick}>update-badge</button>
|
|
),
|
|
}));
|
|
|
|
vi.mock("@/components/mcp/McpPanel", () => ({
|
|
default: ({ open, onOpenChange }: any) =>
|
|
open ? (
|
|
<div data-testid="mcp-panel">
|
|
<button onClick={() => onOpenChange(false)}>close-mcp</button>
|
|
</div>
|
|
) : (
|
|
<button onClick={() => onOpenChange(true)}>open-mcp</button>
|
|
),
|
|
}));
|
|
|
|
const renderApp = (AppComponent: ComponentType) => {
|
|
const client = new QueryClient();
|
|
return render(
|
|
<QueryClientProvider client={client}>
|
|
<Suspense fallback={<div data-testid="loading">loading</div>}>
|
|
<AppComponent />
|
|
</Suspense>
|
|
</QueryClientProvider>,
|
|
);
|
|
};
|
|
|
|
describe("App integration with MSW", () => {
|
|
beforeEach(() => {
|
|
resetProviderState();
|
|
toastSuccessMock.mockReset();
|
|
toastErrorMock.mockReset();
|
|
});
|
|
|
|
it("covers basic provider flows via real hooks", async () => {
|
|
const { default: App } = await import("@/App");
|
|
renderApp(App);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
"claude-1",
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("switch-codex"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
"codex-1",
|
|
),
|
|
);
|
|
|
|
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();
|
|
fireEvent.click(screen.getByText("confirm-add"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toMatch(
|
|
/New codex Provider/,
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("edit"));
|
|
expect(screen.getByTestId("edit-provider-dialog")).toBeInTheDocument();
|
|
fireEvent.click(screen.getByText("confirm-edit"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toMatch(
|
|
/-edited/,
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("switch"));
|
|
fireEvent.click(screen.getByText("duplicate"));
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toMatch(/copy/),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("open-website"));
|
|
|
|
emitTauriEvent("provider-switched", {
|
|
appType: "codex",
|
|
providerId: "codex-2",
|
|
});
|
|
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
expect(toastSuccessMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it("shows toast when auto sync fails in background", async () => {
|
|
const { default: App } = await import("@/App");
|
|
renderApp(App);
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
"claude-1",
|
|
),
|
|
);
|
|
|
|
expect(() => {
|
|
emitTauriEvent("webdav-sync-status-updated", null);
|
|
}).not.toThrow();
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
|
|
emitTauriEvent("webdav-sync-status-updated", {
|
|
source: "auto",
|
|
status: "error",
|
|
error: "network timeout",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalled();
|
|
});
|
|
|
|
toastErrorMock.mockReset();
|
|
expect(() => {
|
|
emitTauriEvent("s3-sync-status-updated", null);
|
|
}).not.toThrow();
|
|
expect(toastErrorMock).not.toHaveBeenCalled();
|
|
|
|
emitTauriEvent("s3-sync-status-updated", {
|
|
source: "auto",
|
|
status: "error",
|
|
error: "s3 timeout",
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("duplicates openclaw providers with a generated key that avoids live-only ids", async () => {
|
|
setProviders("openclaw", {
|
|
deepseek: {
|
|
id: "deepseek",
|
|
name: "DeepSeek",
|
|
settingsConfig: {
|
|
baseUrl: "https://api.deepseek.com",
|
|
apiKey: "test-key",
|
|
api: "openai-completions",
|
|
models: [],
|
|
},
|
|
category: "custom",
|
|
sortIndex: 0,
|
|
createdAt: Date.now(),
|
|
},
|
|
});
|
|
setCurrentProviderId("openclaw", "deepseek");
|
|
setLiveProviderIds("openclaw", ["deepseek-copy"]);
|
|
|
|
const { default: App } = await import("@/App");
|
|
renderApp(App);
|
|
|
|
fireEvent.click(screen.getByText("switch-openclaw"));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
"deepseek",
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("duplicate"));
|
|
|
|
await waitFor(() => {
|
|
const providerList = screen.getByTestId("provider-list").textContent;
|
|
expect(providerList).toContain("deepseek-copy-2");
|
|
expect(providerList).toContain("DeepSeek copy");
|
|
});
|
|
|
|
expect(toastErrorMock).not.toHaveBeenCalledWith(
|
|
expect.stringContaining("Provider key is required for openclaw"),
|
|
);
|
|
});
|
|
|
|
it("shows toast when duplicate cannot load live provider ids", async () => {
|
|
setProviders("openclaw", {
|
|
deepseek: {
|
|
id: "deepseek",
|
|
name: "DeepSeek",
|
|
settingsConfig: {
|
|
baseUrl: "https://api.deepseek.com",
|
|
apiKey: "test-key",
|
|
api: "openai-completions",
|
|
models: [],
|
|
},
|
|
category: "custom",
|
|
sortIndex: 0,
|
|
createdAt: Date.now(),
|
|
},
|
|
});
|
|
setCurrentProviderId("openclaw", "deepseek");
|
|
|
|
const liveIdsSpy = vi
|
|
.spyOn(providersApi, "getOpenClawLiveProviderIds")
|
|
.mockRejectedValueOnce(new Error("broken config"));
|
|
|
|
const { default: App } = await import("@/App");
|
|
renderApp(App);
|
|
|
|
fireEvent.click(screen.getByText("switch-openclaw"));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByTestId("provider-list").textContent).toContain(
|
|
"deepseek",
|
|
),
|
|
);
|
|
|
|
fireEvent.click(screen.getByText("duplicate"));
|
|
|
|
await waitFor(() => {
|
|
expect(toastErrorMock).toHaveBeenCalledWith(
|
|
expect.stringContaining("读取配置中的供应商标识失败"),
|
|
);
|
|
});
|
|
|
|
expect(screen.getByTestId("provider-list").textContent).not.toContain(
|
|
"deepseek-copy",
|
|
);
|
|
|
|
liveIdsSpy.mockRestore();
|
|
});
|
|
});
|