返回博客

开发工具用自己的 repo 做测试,上线秒崩——「外部输入盲区」踩坑实录

写了一个接受「项目路径」作为参数的工具——CLI、Mac App、语言服务器都算。本地测试跑了几周,verifier 全绿,smoke 全绿,自信满满打包上线。结果第一个用户一打开,选了自己的项目目录,工具立刻崩了。

错误信息长这样:Missing CLI at /Users/someone/Desktop/their-project/bin/my-tool.mjs

那个路径是用户的项目目录拼上了工具的内部路径。用户完全不知道这个 bin/my-tool.mjs 是什么东西。

现象

  • 开发期间 npm test / make smoke 全程绿灯,连续几周没出问题
  • source-grep 验证器也通过了——确实能在代码里找到 ["node", cli.path, ...] 这行
  • 打包成 .app 分发给第一个用户,用户选了自己的工作目录,工具抛出 ENOENT
  • 错误路径 = 用户选的目录 + 工具内部某个硬编码后缀,这个文件只存在于工具自身的 repo 里

根因

整个开发过程中,「工具接受的输入」和「工具的源码 repo」始终是同一个目录。

工具里有一行代码把「用户提供的项目路径」拼接上了工具自身的可执行文件路径:

// 错误写法:从用户选的目录里找 CLI
let cliPath = projectRoot.appendingPathComponent("bin/my-tool.mjs")

这行代码在 dev 环境完全正确——开发者在工具自己的 repo 里测试,projectRoot 就是工具 repo,bin/my-tool.mjs 当然存在。但这其实是工具资源,不是用户输入里应该有的东西

三层遮蔽让这个 bug 隐藏了很久:

  1. Fixture 巧合:开发者写了这个文件,所以 dev 环境里自然存在
  2. Verifier 完整性幻觉:source-grep 只能确认代码里有这行路径拼接,不能验证「这个路径在用户的目录里存不存在」
  3. Smoke 无外部参数:smoke test 不带参数运行,默认用 dev 环境,永远只测开发者自己的分支
坑:开发工具的 repo 和工具的输入对象重合,隐藏了所有耦合假设

只要工具的测试目标始终是它自己的源码目录,任何「工具资源应该在工具安装位置,不该在用户项目里」的耦合都会被掩盖。verifier 再多也检测不到,因为 verifier 本身也跑在同一个满足假设的环境里。

打包成 .app / brew formula / npm 全局包之后,工具和用户输入物理分离,假设立刻击穿。

正解

分离「工具资源」与「用户输入」

工具自带的可执行文件、schema、默认配置,必须从工具的安装位置解析,不能拼接用户的项目路径。

Mac App(Swift)正确写法

// 从 bundle 里找 CLI,projectRoot 只是工作区
let bundleResources = Bundle.main.resourceURL!
let cliPath = bundleResources.appendingPathComponent("my-tool-cli/bin/my-tool.mjs")
 
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["node", cliPath.path, "serve", "--project", projectRoot.path]
process.currentDirectoryURL = projectRoot  // 工作区 cwd,不是 CLI 安装目录

Node CLI 正确写法

// __dirname 是 CLI 的安装目录,不是用户的项目路径
const helper = require(path.join(__dirname, '..', 'lib', 'helper.mjs'));

加一个「外部输入」Fixture 测试

这是最关键的长效防护。在测试矩阵里加至少一个用完全不相关目录作为输入的 smoke:

// e2e 或 smoke test
const foreignInput = await fs.mkdtemp(path.join(os.tmpdir(), 'foreign-'));
// foreignInput 里什么工具相关文件都没有
const result = await runToolAgainstInput(foreignInput);
assert(!/Missing|ENOENT|Cannot find/.test(result.stderr),
       `工具必须能在外部输入上启动,实际输出: ${result.stderr}`);

外部输入可以是:

  • mkdtemp() 空目录
  • 只有 package.json 的最小 Node 项目目录
  • 任意一个与工具无关的小 repo

用 source-grep 锁住正确模式

修复后,用 verifier 阻断回归:

assert(shellSource.includes('Bundle.main.resourceURL'),
       'CLI 必须从 bundle 解析,不能从用户目录解析');
assert(!shellSource.includes('projectRoot.appendingPathComponent("bin/'),
       '禁止在用户项目路径下查找工具 CLI');
正解:工具资源从安装位置解析,外部输入 fixture 测试兜底

修复点只有一处:把所有 userProjectPath + "/tool-internal-file" 改成 __dirnameBundle.main.resourceURL。然后加一个 mkdtemp 外部 fixture 测试,确保工具能对着一个空目录正常启动。最后用 source-grep verifier 防止同类写法再混入代码。

一句话外卖

任何接受「外部路径」作为输入的工具,测试矩阵里必须有至少一个「与工具 repo 无关的目录」作为输入对象——否则开发期的狗粮测试只是在验证「开发者环境满足假设」,不是在验证「工具本身正确」。