无头验收
GUI 应用需要在没有显示器、没有人的机器上证明它还能工作。要建两个开关,不是一个。
--shot <path> —— 视觉验收
渲染几帧、存 PNG、退出:
$shot = shot_path($argv); // 从 argv 读 `--shot <path>`
if ($shot !== '') {
$app->runFrames(3);
$app->snapshot($shot);
$app->destroy();
return;
}
qtphp run . --shot out.png
把 PNG 读回来看一眼 —— 这是确认布局改动最省事的办法。
用命令行参数,不要用环境变量
往 .bat 里从 shell 传环境变量要穿过好几层引号,很脆。参数是可靠的。示例的 run.bat shot.png 就是这么做的。
把 PNG 变成 CI 基线
一张可复现的 PNG 就是一条免费的回归测试。三件事必须同时成立,每条都在 examples/hello 上实测过:
- 屏幕上不能有随墙钟变的东西。 那个 1 秒心跳标签让相邻两次出图差在
(25,366)–(159,714), 直到把定时器注册点移到--shot分支之后才消失;打印时间的控件同理。 - 瞬态动画必须已经停下来 —— 这件事
snapshot()替你做掉。 以前只泵几帧时抓图是抛硬币: 8 次跑出 4 种哈希,差异全在(622,66)-(636,80)那一格 —— 抓到的是 QLineEdit 清除按钮 淡入淡出的中途。泵满 120 帧能把它藏住(代价是白等多帧、出图从几百毫秒变成秒级);现在snapshot()在抓之前 把还在跑的动画直接推到终点值,所以 3 帧就够,帧也不再取决于墙钟相位。 - 抓帧前要清焦点。 有焦点的 QLineEdit 画的是 Fusion 高亮框(
#7e9dc2)加光标, 没焦点是普通#b6b6b6边框,落到哪一边是竞态。snapshot()自己做了clearFocus()与还原。
然后先验证收敛,再信那个数 —— 判据是从不同瞬态起点必须收敛成同一帧:
for i in $(seq 8); do ./build/hello --shot /tmp/s$i.png; done
shasum -a 256 /tmp/s*.png | awk '{print $1}' | sort -u | wc -l # 必须输出 1
examples/hello 在 Apple Silicon 上当前的值(改一次界面就会变,把它当「这个产物的指纹」,不是常量):
| 平台 / 后端 | sha256 |
|---|---|
cocoa,启用 WKWebView | 2728fb1a6a0652625483166097bda44a23f051f48e629b7acf9dac793ce1e4d7 |
QT_QPA_PLATFORM=offscreen,启用 WKWebView | 6c4832a6b738145ef902a131f98ff832e769d2eb5aa1485b2295502ec9b0111b |
cocoa,把 -DQT_WEBVIEW_WK 注释掉(QTextBrowser) | a70cd7ae05b6f3099ee799204547ba040d35be4f93cc3bc8af4494e81a7d9bbc |
哈希只能证明「没变」,是单向的
两次哈希相同 ⇒ 像素没变(成立)。但它不能证明功能是对的:原生 webview 后端画在 Qt 之外, QWidget::grab() 那里永远是空洞,页面显示什么都一样 (见 WebView)。验那条路要用事件,不要用像素。
--selftest —— 行为验收
逐个触发所有已注册事件,每个用例报 ok / FAIL:
$app->headless(true); // 模态对话框不能阻塞 —— 见 AOT 注意事项
$app->run(1);
$app->dispatch(['type' => 'click', 'id' => 'greet_btn']);
$app->dispatch(['type' => 'submit', 'id' => 'name_input', 'value' => 'Ada']);
// … 每个控件一条 …
$failed = $app->lastError();
echo $failed === '' ? "selftest passed\n" : "selftest failed: $failed\n";
$app->destroy();
if ($failed !== '') {
exit(1); // CI 按退出码判定,不去抓输出文本
}
qtphp run . --selftest
ok click greet_btn
ok submit name_input
ok toggle dark_toggle
ok change theme_combo
…
selftest passed
这个开关才是真正有价值的那个
闭包参数个数陷阱对单元测试和"不点任何东西"的冒烟测试都是隐形的 —— 它只在你真的触发那个控件时才炸。--selftest 无头地把它们全触发一遍,几秒钟跑完。
让应用能程序化地报出最后一次错误(lastError()),这样检查能打印为什么失败而不只是失败了。
--difftest —— diff 边界
表格/树的差异更新边界(选中、行 id、列数变化、补丁)只能跑在真 Qt 上 —— diff 引擎和 patch() 的 call 都在 C++ 里,PHPUnit 摸不到。
qtphp run . --difftest
示例应用的 20 条断言就在 examples/hello/src/main.php 的 diff_test() 里,照着写自己应用的边界断言即可。
退出码
三个开关都通过进程退出码报告结果,CI 直接判 rc,不必去 grep 输出:
| 开关 | 0 | 1 |
|---|---|---|
--selftest | 全部用例 ok | 至少一条 FAIL |
--difftest | 全部断言 ok | 至少一条 FAIL |
--shot <path> | PNG 已写出 | snapshot() 返回 false(路径不可写 / 平台插件不可用) |
qtphp run 原样透传应用的退出码;qtphp package 会把 bundle 内 --selftest 的非零退出码 当成打包失败。
三个开关的分工
| 开关 | 验证什么 | 能跑在 |
|---|---|---|
--shot | 布局、渲染、中文字体 | 真 Qt(可 offscreen) |
--selftest | 每个处理器可调用、AOT 陷阱 | 真 Qt(可 offscreen) |
--difftest | diff 引擎与 patch() 的 C++ 行为 | 真 Qt(可 offscreen) |
三者都不挑平台。要在无 GUI 会话(CI)里跑,设 QT_QPA_PLATFORM=offscreen。
offscreen 下 --shot 检查不了中文
Qt 的 offscreen 平台插件走自己的字体枚举,可能取不到中文字体, 于是 --shot 出的图里中文全是方框。--selftest / --difftest 不受影响 (它们不渲染像素)。
界面有中文又想看图时,--shot 就别加 QT_QPA_PLATFORM=offscreen (需要有桌面会话);另外两个开关继续用 offscreen 没问题。
Windows 上 qtphp build 会把 qwindows.dll / qoffscreen.dll / qminimal.dll 一起部署到 build/platforms/,所以这三个开关在开发产物上直接可用。
无头模式做了什么
headless(true) 让所有阻塞调用立即返回,不碰 Qt 的模态 API:
| 调用 | 无头时 |
|---|---|
message() / alert() / error() | 返回 spec['default'](或 'ok') |
confirm() | 返回 false |
openFile() / saveFile() | 返回 [] |
pickDirectory() | 返回 '' |
notify() | 直接返回(无托盘时它本来会回退成模态框) |
不设它的话,任何一个对话框都会让 CI 永久挂住。
在 CI 里跑
# 编译
qtphp build .
# 行为验收(无显示器)
QT_QPA_PLATFORM=offscreen qtphp run . --selftest
# 视觉验收(把 PNG 当构建产物存档,人工看或做像素比对)
QT_QPA_PLATFORM=offscreen qtphp run . --shot out.png
# 单元测试(不需要 Qt,也不需要编译器)
qtphp test
# 契约校验
qtphp lint
验收打包产物
打包产物也要能无头验收 —— 这才能证明"打包没漏东西"。
# macOS:bundle 必须自带 offscreen 插件
env -i QT_QPA_PLATFORM=offscreen PATH=/usr/bin:/bin HOME="$HOME" \
dist/MyApp.app/Contents/MacOS/myapp --selftest
# Linux:包内已有整个 platforms/ 目录
cd dist/myapp && env -i QT_QPA_PLATFORM=offscreen ./myapp --selftest
env -i 清空环境,不让构建机的任何东西漏进来 —— 和 Windows 上把 PATH 缩到 C:\Windows\System32 是同一个思路。
macOS 的一个坑
macdeployqt 只按目标平台拷插件 —— 只带 libqcocoa.dylib 的 bundle 在设了 QT_QPA_PLATFORM=offscreen 时会被 Qt 直接 abort(rc=134)。
qtphp package 会自动把 libqoffscreen.dylib 补拷进 Contents/PlugIns/platforms/ 并改写 Qt 引用(约 +156 KB)。见打包。
单元测试与无头验收的分工
qtphp test(PHPUnit + FakeBridge) | --selftest(真实 AOT 二进制) | |
|---|---|---|
| 需要 Qt | ❌ | ✅ |
| 需要编译器 | ❌ | ✅(已编译) |
| 速度 | 毫秒 | 秒 |
| 能抓 AOT 陷阱 | ❌ 结构上不能 | ✅ |
| 能抓逻辑 bug | ✅ | 一般 |
两个都要。 前者快、能覆盖领域逻辑;后者是唯一能证明"编译产物真的能用"的检查。