一个看似正确的回滚逻辑,恢复的却是一个空目录——而且配套的测试还通过了。这不是段子,是谷歌终端代理 gemini-cli(Apache-2.0 开源协议)里真实存在的代码路径。
问题出在packages/cli/src/config/extensions/update.ts这个文件里。作者在阅读 gemini-cli 的扩展更新流程时,发现它的回滚机制从构造上就注定了恢复的是空目录——不是"恢复错了东西",而是每次都会恢复一个空目录。
![]()
问题藏在哪
这段代码的骨架是开发者们再熟悉不过的写法:先创建临时目录,然后执行更新操作,如果失败就回滚,最后清理临时目录。伪代码大致是这样:
const tempDir = await ExtensionStorage.createTmpDir();try {// 加载旧配置,然后安装新版本到 extension.pathawait extensionManager.installOrUpdateExtension(/* ... */);// 分发 UPDATED 或 UPDATED_NEEDS_RESTART 事件} catch (e) {await copyExtension(tempDir, extension.path);} finally {await fs.promises.rm(tempDir, { recursive: true, force: true });单独看 catch 块,逻辑完全正确:更新失败了,就把旧扩展从临时目录拷回来。但对照上面那行代码再看,问题就暴露了——tempDir 是 createTmpDir 创建出来的目录,从头到尾没有任何人往里写过东西。更新操作是直接修改 extension.path 的,没有任何一步把当前安装的扩展先复制到 tempDir 里。所以恢复路径拿一个空目录,覆盖到更新了一半的扩展上——这比它要处理的原始故障更糟糕。
测试通过了,但测了个寂寞
更讽刺的是,这个回滚逻辑是有测试的,而且测试通过了。断言长这样:
expect(copyExtension).toHaveBeenCalledWith('/tmp/mock-dir', extension.path);这个断言是真的,一直是真的。copyExtension 确实被调用了,参数也确实是临时目录作为源、扩展路径作为目标,完全符合回滚的意图。调用发生了,测试验证了调用发生了。
但没有任何断言覆盖一个关键问题:源目录里到底有没有东西。
这是测试盲区,不是 mock 失误
作者认为这不是一个简单的 mock 写错的问题,而是为恢复路径写测试时必然遇到的结构性盲区。你 mock 文件系统,因为你不想要一个真实的文件系统。一旦 copyExtension 变成了 mock,测试里唯一可观察的东西就只剩调用本身:函数名、参数、调用顺序。目录的内容在测试里根本不存在这个概念。
于是你只能断言仍然可见的东西,然后感觉有了覆盖——因为一个叫"失败时恢复"的测试是绿的,而且下面确实有一个真实的断言。
缺失的断言是顺序性的
真正缺的断言不是"copyExtension 是否被调用、参数对不对",而是"在更新操作触碰 extension.path 之前,是否有东西被复制进了 tempDir"。这个断言在现有代码上会失败,而且失败的原因正是问题的根源。
修复方案也很直接:在更新操作之前先把扩展复制到 tempDir,同时维护一个 backedUp 标志,让回滚只在确实存在真实备份时才执行:
if (backedUp) {await copyExtension(tempDir, extension.path);这个标志不是多余的防御——它把"回滚"从一种仪式性的动作变成了有实际意义的操作。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.