远程执行 iOS 测试时,剪贴板相关功能很容易落入“本机点一下能用,流水线无法验证”的盲区。邀请码导入、调试命令粘贴、多行文本清洗和应用内复制都依赖系统剪贴板,但普通单元测试往往绕过了真实入口。更稳妥的办法是给每个任务分配独立模拟器,用 simctl 注入固定文本,再让 UI 测试执行用户可见的粘贴动作。
先定义测试边界
这套流程适合验证纯文本、Unicode、空白字符和换行处理,不应把 pbcopy 当成任意二进制数据注入器。图片、文件 URL 或自定义 UTI 应由应用内测试夹具或专用测试宿主提供。
先列出真正需要保护的行为:
- 单行文本能否从明确的粘贴入口进入表单;
\n与\r\n是否按产品规则归一化;- 中文、组合字符和 emoji 是否保持完整;
- 复制结果是否包含多余空格或不可见字符;
- 剪贴板为空时是否给出可理解的界面状态;
- 测试结束后是否清理了跨任务状态。
自动化目标不是直接读取系统剪贴板,而是验证用户触发复制或粘贴后,应用呈现的结果是否正确。
为每个任务准备干净模拟器
并行任务不能共用 booted 这个模糊目标。两个任务同时执行 pbcopy 时,后写入的内容会覆盖前一个任务。应由调度层传入固定 UDID,并给 DerivedData 与结果包分配独立目录。
set -euo pipefail
: "${IOS_UDID:?IOS_UDID is required}"
JOB_ROOT="${PWD}/artifacts/${CI_JOB_ID:-local}"
mkdir -p "$JOB_ROOT"
xcrun simctl shutdown "$IOS_UDID" 2>/dev/null || true
xcrun simctl erase "$IOS_UDID"
xcrun simctl boot "$IOS_UDID"
xcrun simctl bootstatus "$IOS_UDID" -b
erase 会清除应用容器和模拟器设置,适合追求确定性的验收任务。若流水线需要复用已安装应用来缩短准备时间,就不要省略清理设计:至少卸载被测应用、清空剪贴板,并记录运行时版本与 UDID。
| 隔离对象 | 推荐做法 | 共用后的典型问题 |
|---|---|---|
| 模拟器 | 每个任务一个 UDID | 剪贴板与应用状态互相覆盖 |
| DerivedData | 按任务分目录 | 并发写入导致构建结果不稳定 |
| 结果包 | 每次生成新路径 | 旧附件混入本次失败记录 |
| 文本夹具 | 纳入版本控制 | 输入变化但测试代码未变化 |
注入可审计的文本夹具
不要在 Shell 中直接拼复杂 Unicode 字符串。把夹具存成 UTF-8 文件,代码审查时才能看见输入发生了什么变化。
mkdir -p Tests/ClipboardFixtures
printf '第一行\n第二行:café\nemoji:🧪\n' \
> Tests/ClipboardFixtures/multiline.txt
xcrun simctl pbcopy "$IOS_UDID" \
< Tests/ClipboardFixtures/multiline.txt
夹具应覆盖正常输入和边界输入,但不要把所有情况塞进一个超长文件。建议分别维护单行、多行、前后空白、组合字符和空内容用例。测试失败时,日志只记录夹具文件名、字节数和摘要,不要无条件输出实际剪贴板内容,因为其中可能包含敏感数据。
注入后可以在调试阶段读取一次,确认问题发生在注入侧还是应用侧:
xcrun simctl pbpaste "$IOS_UDID" > "$JOB_ROOT/clipboard-before.txt"
cmp Tests/ClipboardFixtures/multiline.txt \
"$JOB_ROOT/clipboard-before.txt"
生产流水线不必长期保存这份原文。确认一致后删除文件,或仅保存 shasum -a 256 的结果。
用真实入口触发粘贴与复制
应用应提供用户可识别的粘贴控件,而不是在页面加载时偷偷读取剪贴板。UI 测试点击该控件,然后检查界面结果,这样既覆盖交互路径,也避免把后台读取误当成功能正常。
func testPasteMultilineFixture() {
let app = XCUIApplication()
app.launchArguments += ["-ui-testing"]
app.launch()
let pasteButton = app.buttons["clipboard.paste"]
XCTAssertTrue(pasteButton.waitForExistence(timeout: 10))
pasteButton.tap()
let editor = app.textViews["clipboard.editor"]
XCTAssertTrue(editor.waitForExistence(timeout: 5))
XCTAssertEqual(
editor.value as? String,
"第一行\n第二行:café\nemoji:🧪"
)
}
如果系统显示粘贴确认界面,测试应把它视为交互的一部分,而不是通过内部 API 绕过。控件的 accessibility identifier 要稳定,不要依赖本地化后的按钮文字。
复制方向可以反过来验证:UI 测试输入固定内容并点击复制,测试结束后再用 pbpaste 导出结果。这样能发现尾随换行、富文本降级或错误的空白清理。
运行、取证与排错
先完成构建,再运行测试,可减少“编译失败”和“交互失败”混在同一份日志中的情况。
xcodebuild build-for-testing \
-scheme ClipboardApp \
-destination "platform=iOS Simulator,id=$IOS_UDID" \
-derivedDataPath "$JOB_ROOT/DerivedData"
xcodebuild test-without-building \
-scheme ClipboardApp \
-destination "platform=iOS Simulator,id=$IOS_UDID" \
-derivedDataPath "$JOB_ROOT/DerivedData" \
-resultBundlePath "$JOB_ROOT/ClipboardTests.xcresult"
失败时按顺序检查:UDID 是否属于当前任务、模拟器是否完成启动、夹具哈希是否一致、应用是否真的点击了粘贴控件、断言读取的是否为最终界面状态。不要一失败就重复运行;重跑可能覆盖竞争条件,反而丢失现场。
可以为任务设置退出清理,但保留失败时的 xcresult、模拟器日志和截图。剪贴板原文应默认删除,只留下长度、编码、摘要及夹具名称。若测试涉及用户可能输入的内容,日志脱敏必须在归档前完成。
把检查项固化到流水线
稳定的剪贴板回归任务至少应满足以下条件:
- UDID、DerivedData 和结果目录均按任务隔离;
- 模拟器运行时版本被记录;
- 文本夹具以 UTF-8 文件进入版本控制;
- 测试通过用户动作触发粘贴或复制;
- 空剪贴板、Unicode 与换行分别有独立用例;
- 失败时保存界面证据,不长期保留剪贴板原文;
- 任务退出后清理模拟器中的临时状态。
这套测试的价值不在于覆盖系统剪贴板本身,而在于把应用与剪贴板之间的契约变成可重复验收的输入和输出。当一次失败能对应到具体 UDID、夹具摘要、测试步骤与结果包时,远程排查就不再依赖人工复述。
常见问题
simctl 的 pbcopy 能直接覆盖图片剪贴板测试吗?
不能把它当作通用二进制剪贴板注入器。pbcopy 和 pbpaste 更适合稳定验证文本;图片、文件 URL 与自定义 UTI 应通过应用内测试夹具或专用测试宿主注入。
并行任务可以共用同一个 iOS 模拟器吗?
不建议。每个任务应绑定独立 UDID、DerivedData 和结果目录,否则剪贴板、应用容器与启动状态会互相覆盖,产生无法复现的失败。
在云端 M4 Mac 上运行下一项工作
选择 Runner M4 或 Runner M4 Plus,按项目需要确定节点、租期和存储附加项。每笔订单对应独享物理机,非虚拟机。