TypePHP\Qt
指南
控件
深入
参考
FAQ
  • English
  • 简体中文
GitHub
指南
控件
深入
参考
FAQ
  • English
  • 简体中文
GitHub
  • 深入

    • 深入
    • AOT 注意事项
    • 手写桥接
    • diff 引擎
    • 无头验收

无头验收

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. 屏幕上不能有随墙钟变的东西。 那个 1 秒心跳标签让相邻两次出图差在 (25,366)–(159,714), 直到把定时器注册点移到 --shot 分支之后才消失;打印时间的控件同理。
  2. 瞬态动画必须已经停下来 —— 这件事 snapshot() 替你做掉。 以前只泵几帧时抓图是抛硬币: 8 次跑出 4 种哈希,差异全在 (622,66)-(636,80) 那一格 —— 抓到的是 QLineEdit 清除按钮 淡入淡出的中途。泵满 120 帧能把它藏住(代价是白等多帧、出图从几百毫秒变成秒级);现在 snapshot() 在抓之前 把还在跑的动画直接推到终点值,所以 3 帧就够,帧也不再取决于墙钟相位。
  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,启用 WKWebView2728fb1a6a0652625483166097bda44a23f051f48e629b7acf9dac793ce1e4d7
QT_QPA_PLATFORM=offscreen,启用 WKWebView6c4832a6b738145ef902a131f98ff832e769d2eb5aa1485b2295502ec9b0111b
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 输出:

开关01
--selftest全部用例 ok至少一条 FAIL
--difftest全部断言 ok至少一条 FAIL
--shot <path>PNG 已写出snapshot() 返回 false(路径不可写 / 平台插件不可用)

qtphp run 原样透传应用的退出码;qtphp package 会把 bundle 内 --selftest 的非零退出码 当成打包失败。

三个开关的分工

开关验证什么能跑在
--shot布局、渲染、中文字体真 Qt(可 offscreen)
--selftest每个处理器可调用、AOT 陷阱真 Qt(可 offscreen)
--difftestdiff 引擎与 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✅一般

两个都要。 前者快、能覆盖领域逻辑;后者是唯一能证明"编译产物真的能用"的检查。

在 GitHub 上编辑此页
最后更新: 2026/10/6 03:13
Prev
diff 引擎