云端 Mac 上编译出一个命令行程序后,最容易漏掉的不是编译错误,而是调用契约:帮助信息混进数据流,失败时仍返回零,或者工具只在开发者当前目录下能运行。下面用一个小型 Swift 工具建立交付前验收。示例不依赖图形会话,适合放进无人值守的构建任务。
先写清调用契约
示例工具 taskprobe 接收 --output 和一段文本,将文本写入指定文件。文件路径是必填参数;帮助请求成功退出,参数错误则失败。先约定调用方如何判断结果,再实现功能。
| 场景 | 标准输出 | 标准错误 | 退出码 | 文件 |
|---|---|---|---|---|
--help |
用法说明 | 空 | 0 | 不创建 |
| 参数缺失 | 空 | 错误说明 | 非零 | 不创建 |
| 正常写入 | 完成标记 | 空 | 0 | 内容完整 |
| 路径不可写 | 空 | 错误说明 | 非零 | 不创建目标 |
标准输出只承担机器可消费的成功结果,标准错误只承担诊断信息。调用脚本必须同时检查退出码和预期文件,不能仅凭日志中出现“完成”判断成功。
原子写入保护的是目标文件不被半截新内容覆盖,不等于断电后数据一定持久化;后者需要另行设计落盘与恢复策略。
编译一个最小可测工具
在独立目录内保存源码并编译。这里不把输出位置写死在程序中;调用者传入绝对路径,测试时才能主动切换工作目录。使用 Foundation 的原子写入选项,避免正常写入过程中向目标路径暴露部分内容。
WORK="$(mktemp -d)"
mkdir -p "$WORK/bin" "$WORK/home"
cat > "$WORK/main.swift" <<'SWIFT'
import Foundation
func fail(_ message: String) -> Never {
FileHandle.standardError.write(Data((message + "\n").utf8))
exit(2)
}
let args = Array(CommandLine.arguments.dropFirst())
if args == ["--help"] {
print("usage: taskprobe --output PATH TEXT")
exit(0)
}
guard args.count == 3, args[0] == "--output",
!args[1].isEmpty else {
fail("expected --output PATH TEXT")
}
let target = URL(fileURLWithPath: args[1])
do {
try Data(args[2].utf8).write(to: target, options: .atomic)
print("written")
} catch {
fail("cannot write output: \(error.localizedDescription)")
}
SWIFT
xcrun swiftc -O "$WORK/main.swift" -o "$WORK/bin/taskprobe"
xcrun 使用当前选定的开发工具链。编译前可运行 xcode-select -p 核对选择;这一步不代替在目标 macOS 版本上实际运行。验收应针对即将交付的同一份二进制文件,不要验收后重新编译,再交付另一份产物。
用独立目录验收成功与失败
下面的脚本承接上一段 shell 会话中的 WORK。每个检查把两个输出流分开保存;测试刻意切到临时工作目录,并清空多数继承的环境变量。若交付工具确实需要某个变量,应把它明确加入契约,而不是依赖交互式 shell 恰好提供。
set -eu
BIN="$WORK/bin/taskprobe"
cd "$WORK/home"
env -i PATH=/usr/bin:/bin HOME="$WORK/home" \
"$BIN" --help >"$WORK/help.out" 2>"$WORK/help.err"
grep -Fx 'usage: taskprobe --output PATH TEXT' "$WORK/help.out"
test ! -s "$WORK/help.err"
if "$BIN" --output >"$WORK/bad.out" 2>"$WORK/bad.err"; then
echo "missing arguments returned success" >&2
exit 1
fi
test ! -s "$WORK/bad.out"
grep -F 'expected --output' "$WORK/bad.err"
"$BIN" --output "$WORK/home/result.txt" 'hello 云端 Mac' \
>"$WORK/good.out" 2>"$WORK/good.err"
grep -Fx 'written' "$WORK/good.out"
test ! -s "$WORK/good.err"
grep -Fx 'hello 云端 Mac' "$WORK/home/result.txt"
if "$BIN" --output "$WORK/home/absent/result.txt" test \
>"$WORK/fail.out" 2>"$WORK/fail.err"; then
echo "unwritable path returned success" >&2
exit 1
fi
test ! -e "$WORK/home/absent/result.txt"
test ! -s "$WORK/fail.out"
test -s "$WORK/fail.err"
第一条失败用例验证参数错误,第二条验证写入错误。不要用 chmod 制造“不可写目录”:测试进程若有特殊权限,结果可能不同。使用不存在的父目录更直接,但它仍只覆盖一种 I/O 失败,不能证明所有磁盘异常都会按预期处理。
排查三种常见假通过
管道掩盖退出码
taskprobe ... | tee output.log 的退出码通常来自管道末端,而不是工具本身。验收脚本优先直接重定向两个流,随后检查命令状态。确实需要管道时,在支持该选项的 shell 中启用 pipefail,并记录使用的 shell。
当前目录恰好正确
相对路径会受启动目录影响。上面的测试从 home 运行二进制,并传入绝对输出路径,就是为了发现隐含的目录假设。真实工具若要读取随包资源,应根据明确的资源位置定位,不能假定任务总从源码根目录启动。
错误文本被当成数据
仅检查“命令打印了东西”没有意义。一个下游任务可能把标准输出直接保存为结果文件。验收应同时断言:失败时标准输出为空、标准错误有内容、退出码非零;成功时标准错误为空,并检查生成文件的实际内容。工具若输出结构化数据,还应验证其格式,而非只比较文件大小。
交付前再检查边界
把验收脚本放在构建产物旁,由同一个构建任务执行。通过后记录二进制的校验值与所选工具链信息,供后续交接核对;运行它的账户应与实际任务账户具有相同的目录访问范围。文件测试应使用一次性目录,避免旧产物让 test -e 意外通过。
本文的测试不能模拟断电、磁盘写满或目标文件被其他进程并发修改,也不能证明程序能在未经测试的系统版本上运行。需要这些保证时,分别补故障注入、并发协调和目标系统验收。租期结束前,把源码、脚本与验收记录迁出,并由接手者在新环境重跑整套检查;“曾经通过”不是新机器上的验收结果。
常见问题
为什么错误信息不能只打印到标准输出?
调用方可能把标准输出重定向为数据文件。错误信息应写入标准错误,并返回非零退出码,避免把报错当成有效数据。
临时文件改名就一定是原子写入吗?
不能。临时文件与目标文件须位于同一文件系统;本文示例使用 Data.write 的 .atomic 选项,验收脚本只检查成功和失败后的可观察结果。
需要一台独享的云端 Mac?
选择机型、节点和计费周期。交付的是独享物理机,实际可用性以控制台实时返回为准。