没有桌面的 CI 里怎么测 CC Switch 的前端
一个 Tauri 应用的前端,跑起来的时候身边是有个 Rust 进程的。组件里随手一句 invoke("get_providers", { appType }),走的是 IPC,另一头是编译进二进制的命令处理函数。可 CI 容器里没有窗口、没有 WebView、更没有那个 Rust 进程——这时候你想跑一个「点了删除按钮之后列表少一项」的组件测试,invoke 该由谁来接?
最省事的答案是 vi.fn() 全部糊掉,返回死数据。CC Switch 没这么干。它在 tests/msw/ 下用四个文件搭了一个假后端,把 invoke 翻译成 HTTP 请求,再让 MSW 去拦。这个绕圈的做法看着别扭,但它换来的东西相当具体,值得拆开看。
先说清楚这篇的依据
本文对着 CC Switch v3.20.1 的仓库快照 3217f725 写,核对日 2026-08-31。所有结论都来自静态读源码——把文件打开、把行号记下来、用 grep 数一遍。我们没有安装过这个桌面应用,也没有跑过 pnpm test:unit,所以下面不会出现任何「跑起来是绿的」「跑了多久」「覆盖率多少」之类的话,那些得真跑才知道。文中提到的行号可以直接去仓库里对。
第一刀:把命令名变成 URL 路径
核心那段在 tests/msw/tauriMocks.ts:16-39:
const TAURI_ENDPOINT = "http://tauri.local"; // :9
vi.mock("@tauri-apps/api/core", () => ({
invoke: async (command, payload = {}) => {
const response = await fetch(`${TAURI_ENDPOINT}/${command}`, { // :18
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload ?? {}),
});
if (!response.ok) { // :26
const text = await response.text();
throw new Error(text || `Invoke failed for ${command}`);
}
const text = await response.text(); // :31
if (!text) return undefined;
try { return JSON.parse(text); } catch { return text; }
},
}));
一行一行看它做了什么交换。invoke(命令名, 参数对象) 被整个换掉,改成往假域名 http://tauri.local 的 /命令名 路径发一个 POST,参数对象当 JSON body。命令名成了 URL 路径——就这一步,把「模拟 IPC」这个陌生问题变成了「模拟 HTTP」这个成熟问题,MSW 那套按路由注册、按用例覆盖的能力全都能直接用上。
比起 vi.fn() 直接返回假数据,多绕的这一圈换来三样东西:
第一,每个命令的桩是独立的路由,可以按命令组织、按命令替换,而不是维护一个巨大的 switch (command)。
第二,server.use() 天然提供了「本条用例临时改这一个命令的返回」的能力。tests/msw/handlers.ts 里的默认桩只放稳定的公共部分,易变的现造——最典型的是 fetch_models_for_config,它根本不在默认 handler 里,完全由 tests/components/PiProviderForm.test.tsx 的 :927 / :1013 / :1089 / :1142 / :1195 / :1247 六处 server.use() 现场提供,每处返回不同的模型列表来构造不同分支。默认桩不去猜业务分支,业务分支自己带桩来。
第三,也是最容易被忽略的一条:请求体真的被 JSON.stringify 序列化了一遍。测试顺带验证了 payload 是可序列化的——而这正好对应 Tauri 的真实约束,invoke 的参数必须能被 serde 处理。用 vi.fn() 直接传对象引用,这条约束就静默消失了。
错误路径也不是特判出来的,是靠 HTTP 状态码模拟的(:26-29):handler 返回非 2xx,invoke 就抛一个带响应体文本的 Error,对应真实 Tauri 里 Rust 命令返回 Err(String)。tests/msw/handlers.ts:88-96 的 switch_provider 就是这么写的,发现供应商 id 不存在,返回 404,前端那边收到的就是一个抛出的异常。顺带一提,翻遍 handlers.ts,显式造错的地方只有这一处(grep -n "status:" tests/msw/handlers.ts)。空响应体也照顾到了(:31-33):文本为空返回 undefined,JSON 解析不了就把原始文本原样返回,分别对应 Rust 侧返回 () 和返回裸 String 的命令。
后端主动推事件的那条路同样被复刻了。tests/msw/tauriMocks.ts:41-66 用一个内存 Map<事件名, Set<回调>> 顶掉 listen,并额外导出一个真机上不存在的 emitTauriEvent,让测试来扮演后端推送。listen 的返回值仍然是 unlisten 闭包,契约没破,组件里 useEffect 的清理逻辑因此能被真实执行到。
三个截断点,该在哪一层下刀
有了假后端,不等于所有测试都从假后端走。把 v3.20.1 快照下的 vi.mock 目标数一遍(grep -rhoE 'vi\.mock\("[^"]+"' tests | sed 's/vi.mock("//' | sort | uniq -c | sort -rn),实际是三条路并存:
| 截断层 | 做法 | 覆盖面 |
|---|---|---|
| 假后端层(默认) | tests/msw/ 的 handler + 状态机 | 不写任何 mock 的文件都走这条 |
| API 封装层 | vi.mock("@/lib/api") | v3.20.1 快照时 15 个文件 |
| invoke 层 | vi.mock("@tauri-apps/api/core") | 只有 tauriMocks.ts 自己,加一个测试文件 |
规律是清楚的:层次越高的截断越省事、也越脆;越低的越接近真机、也越啰嗦。
在 API 封装层下刀,等于承认「这条测试不关心数据怎么来的」,直接给 @/lib/api 塞假实现,MSW 那一层完全绕开。Hook 测试里这么干的最多,因为要断言的是「点了这个按钮,有没有调那个 API、参数对不对」。
而在 invoke 层再截一刀的,全树只有 tests/hooks/useProxyStatus.test.tsx(:20-22)——它用一个裸 invokeMock 顶掉了 HTTP 转译版。收益很直白:可以写 expect(invokeMock).toHaveBeenCalledWith("start_proxy_server")(:131),直接断言命令名;走 MSW 就得改成断言请求本身,绕得多。
代价也是现成的。 同一个后端命令 get_proxy_takeover_status,tests/msw/handlers.ts:350-355 的默认桩返回 4 个 key,而 tests/hooks/useProxyStatus.test.tsx:81-88 的本地 mock 返回 6 个(多了 opencode、openclaw)。两处桩数据对同一个契约的理解不一致——这是我们实读两个文件比对出来的差异,就写到这里为止,我们没有依据判断哪一份才是后端的真实形状,那得去 Rust 侧核。
顺便说一处同类的重复:TAURI_ENDPOINT 这个常量在仓库里被抄了四份,tests/msw/handlers.ts:29、tests/msw/tauriMocks.ts:9、tests/components/PiProviderForm.test.tsx:15、tests/components/ProviderList.test.tsx:10,没有一处是导出复用的。
假后端为什么必须 deepClone
tests/msw/state.ts 是四个文件里最长的一个,本质是一个可变的内存状态机,handlers 只负责把 HTTP 请求翻译成对它的调用。它最讲究的一处设计是:进出都克隆。
:9 那行 import { deepClone } from "@/utils/deepClone"; 值得停一下——它 import 的是产品代码里的 deepClone,不是测试专用实现。读侧的 getProviders(:276-277)、listProviders(:348)、getSettings(:350)、getMcpConfig(:363)、listSessions(:408)全部返回克隆;写侧的 setProviders(:309)、setMcpConfig(:377)、upsertMcpServer(:400)、setSessionFixtures(:436-437)全部克隆后再存。
为什么非克隆不可?因为真实的 Tauri invoke 跨进程走序列化,前端拿到的永远是副本,你在前端改它不会影响后端一个字节。假后端如果图省事直接返回内部对象的引用,那测试里一句 providers[id].name = "x" 就静默改掉了「数据库」,测出来的行为在真机上根本不可能发生。不克隆的假后端,会让测试通过它自己制造的假象。
deepClone 自己也有测试,而且测得挺刁。tests/utils/deepClone.test.ts:10 用 vi.stubGlobal("structuredClone", undefined) 强制走 fallback 分支,断言嵌套对象、数组、Date 都被真正复制且源对象不变,afterEach 里再 vi.unstubAllGlobals() 还原。对照 src/utils/deepClone.ts:主路径用 structuredClone,fallback 里 :23 有一句显式的 if (key === "__proto__") return;。源码注释 :18-22 解释了原因——cloned["__proto__"] = … 走的是 setter,会替换克隆体自己的原型,产生「凭空读得到源对象里那些键」的幽灵属性,而 structuredClone 是把 __proto__ 当普通数据键保留的,两条路径必须对齐。(注释里写了「已实测」,那是原作者的实测,不是我们的。)
状态重置这一层同样有讲究。resetProviderState() 在 tests/setupTests.ts:27 的 afterEach 里被调用,默认数据用的是工厂函数而不是共享常量——createDefaultProviders()、createDefaultCurrent()、createDefaultSessions() 每次调用返回全新对象。道理和 deepClone 是同一个:共享常量会让上一条用例的写入渗到下一条。不过 resetProviderState() 里的 settings 和 MCP 两块不是调工厂函数,而是把对象字面量整个抄了第二遍(:218-225 抄 :98-105,:227-273 抄 :157-203)。同一份数据在文件里写了两遍,改一处忘另一处,就会出现那种「第一条用例正常、第二条开始不对」的幽灵 bug。
tests/setupTests.ts:25-30 的 afterEach 是四连清理,顺序也不是随手排的:cleanup() 卸载 React 树 → resetProviderState() 重置内存后端 → server.resetHandlers() 丢弃 server.use() 的临时覆盖 → vi.clearAllMocks()。先卸载组件再重置数据,避免卸载过程中的 effect 读到半新半旧的状态。注意最后那句是 clearAllMocks 不是 resetAllMocks——只清调用记录、不清 mock 实现,所以文件级 vi.mock 工厂返回的实现是跨用例保留的,要重设行为得测试自己 mockReset()。
两处边界,别当成保证
一是 fetch 家族被整体替换过,而且换法在两个版本之间变了。 tests/msw/tauriMocks.ts:11-14 无条件覆盖了 globalThis 上的 fetch / Headers / Request / Response 四个,用的是 cross-fetch 的导出。查 diff(git diff c39c903..HEAD -- tests/msw/tauriMocks.ts)能看到:v3.19.2 时写的是 import "cross-fetch/polyfill";,一行副作用式 polyfill,只在全局缺失时才填;v3.20.1 已改成显式 import 四个具体导出、无条件覆盖。含义是「缺了才补」不够用,必须强制统一到同一套实现,否则 Request 实例跨实现传递会出问题。同构 fetch 家族要换就得整体换,不能只换 fetch 一个。
二是这套假后端并不拦住所有疏漏。 tests/setupTests.ts:11 是 server.listen({ onUnhandledRequest: "warn" }),用的是 warn 不是 error——打到没注册 handler 的地址不会让测试直接失败,只会打一条警告。对一个把所有 invoke 都翻译成 HTTP 的架构来说,这意味着「某个命令忘了写桩」不会被测试基建本身拦下来,只会以别的形式暴露。而默认桩本来就只覆盖了后端命令面的一小部分,剩下的要么由单个文件用 server.use() 临时补,要么在更高层被整个绕开。这两件事叠在一起,结论只能是:跑绿了不等于那条命令被测过。
Windows 侧的一句提醒
假后端把路径也一起假掉了。tests/msw/tauriMocks.ts:71-74 里,homeDir 恒返回 /home/mock,join 就是拿 "/" 把片段拼起来——不做任何 Windows 反斜杠处理,也不做 .. 归一化。
所以别指望前端测试能替你验证路径逻辑。任何依赖真实路径语义的行为(盘符、UNC、分隔符差异)在这一层是测不到的,那部分功夫全在 Rust 侧。这对 Windows 用户尤其要紧,因为路径正是 Windows 上最容易出岔子的一块。仓库里确实有专门针对 Windows 与 WSL2 文件系统的后端测试和 CI 流水线,那是另一个话题,我们另有一篇专门讲。
本文依据 CC Switch 官方仓库(github.com/farion1231/cc-switch)的 README、docs/ 下的用户手册、路由指南与发布说明,以及 src/、src-tauri/、tests/ 的源码整理,核对日 2026-08-31,对应仓库快照 3217f725(仓库内版本号 3.20.1)。本文内容为仓库源码与文档口径,我们没有安装或运行过这个桌面应用,因此不涉及界面外观、操作手感与切换速度的任何描述。文中出现的阈值与默认值均为源码中的默认配置,不构成对实际运行结果的保证。该项目仍在快速迭代,版本与默认值随时可能变动,请以仓库最新内容为准。
留言讨论
评论发布后会被人工复核,违规内容将被删除。
如果发表没有反应,可以前往联系我们告诉我们。