Runner 工程文章

云端 Mac 上用 simctl 建立 iOS 剪贴板回归测试

云端 Mac 上用 simctl 建立 iOS 剪贴板回归测试

远程执行 iOS 测试时,剪贴板相关功能很容易落入“本机点一下能用,流水线无法验证”的盲区。邀请码导入、调试命令粘贴、多行文本清洗和应用内复制都依赖系统剪贴板,但普通单元测试往往绕过了真实入口。更稳妥的办法是给每个任务分配独立模拟器,用 simctl 注入固定文本,再让 UI 测试执行用户可见的粘贴动作。

先定义测试边界

这套流程适合验证纯文本、Unicode、空白字符和换行处理,不应把 pbcopy 当成任意二进制数据注入器。图片、文件 URL 或自定义 UTI 应由应用内测试夹具或专用测试宿主提供。

先列出真正需要保护的行为:

自动化目标不是直接读取系统剪贴板,而是验证用户触发复制或粘贴后,应用呈现的结果是否正确。

为每个任务准备干净模拟器

并行任务不能共用 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、模拟器日志和截图。剪贴板原文应默认删除,只留下长度、编码、摘要及夹具名称。若测试涉及用户可能输入的内容,日志脱敏必须在归档前完成。

把检查项固化到流水线

稳定的剪贴板回归任务至少应满足以下条件:

  1. UDID、DerivedData 和结果目录均按任务隔离;
  2. 模拟器运行时版本被记录;
  3. 文本夹具以 UTF-8 文件进入版本控制;
  4. 测试通过用户动作触发粘贴或复制;
  5. 空剪贴板、Unicode 与换行分别有独立用例;
  6. 失败时保存界面证据,不长期保留剪贴板原文;
  7. 任务退出后清理模拟器中的临时状态。

这套测试的价值不在于覆盖系统剪贴板本身,而在于把应用与剪贴板之间的契约变成可重复验收的输入和输出。当一次失败能对应到具体 UDID、夹具摘要、测试步骤与结果包时,远程排查就不再依赖人工复述。

常见问题

simctl 的 pbcopy 能直接覆盖图片剪贴板测试吗?

不能把它当作通用二进制剪贴板注入器。pbcopy 和 pbpaste 更适合稳定验证文本;图片、文件 URL 与自定义 UTI 应通过应用内测试夹具或专用测试宿主注入。

并行任务可以共用同一个 iOS 模拟器吗?

不建议。每个任务应绑定独立 UDID、DerivedData 和结果目录,否则剪贴板、应用容器与启动状态会互相覆盖,产生无法复现的失败。

独享物理节点

在云端 M4 Mac 上运行下一项工作

选择 Runner M4 或 Runner M4 Plus,按项目需要确定节点、租期和存储附加项。每笔订单对应独享物理机,非虚拟机。

立即租用云端 Mac