diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e03b797..d08d205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -96,6 +96,7 @@ jobs: # 启动 GUI 进程,让 Slint 软件渲染器把当前场景渲进内存像素缓冲并编码成 PNG,再对**像素本身** # 断言(不是只看退出码:窗口起来了但一帧空白同样是「退出码 0」)。 # 诚实边界:证明「Linux 上真能跑出非空白的一帧」,**不**证明视觉正确——视觉靠人眼看 artifact。 + # 本 job 绑死 Slint Window::take_snapshot;迁 iced 后必须提供等价的离屏截图通道,否则本 job 静默退化成「能起进程」。 runtime-smoke: name: runtime smoke (ubuntu x64, xvfb) runs-on: ubuntu-latest @@ -118,7 +119,7 @@ jobs: - run: cargo build --bin unlucky-launcher --locked - name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败) run: | - set -o pipefail + set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播 mkdir -p "$RUNNER_TEMP/smoke/data" xvfb-run -a --server-args="-screen 0 1280x1024x24" \ target/debug/unlucky-launcher --software-renderer \ @@ -155,7 +156,7 @@ jobs: - run: cargo build --bin unlucky-launcher --locked - name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论) run: | - set -o pipefail + set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播 mkdir -p "$RUNNER_TEMP/smoke/data" target/debug/unlucky-launcher --software-renderer \ --screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \ diff --git a/packaging/README.md b/packaging/README.md index a5184ef..7d53747 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -344,47 +344,22 @@ experimental / not part of this release matrix)——改一边必须同步另 ## 11. CI 运行时冒烟(Linux 真渲染一帧) -「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境** -运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的 -进程内截图通道(`--software-renderer --screenshot --data-dir `)渲出一帧 PNG,再对**像素本身**断言。 +**细节与依据在 [`packaging/ci-runtime-smoke.md`](ci-runtime-smoke.md)**(本节再增长也往那里写,两个文件都必须 ≤400 行)。 -三道断言(`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`): -① 进程退出码 0(截图通道失败会以非 0 退出,不会留下半张图)② PNG 存在、可解析、宽高 > 0 -③ **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。 +`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**运行证据:装 `xvfb` + 软渲染 +依赖 + 字体,用 `xvfb-run` 起 GUI 进程,走既有进程内截图通道渲出一帧 PNG,再对**像素本身**断言 +(退出码 0 / PNG 尺寸 / 非空白三项,artifact `runtime-smoke-screenshot`)。 -为什么不能只看退出码:窗口起来了却什么都没画(字体库缺失、软渲染降级失败)同样是「退出码 0 + 一张合法 PNG」。 -阈值不凭空定,两组对照实测(`python packaging/check_screenshot.py --selftest` 可复现后三行): +要点三条: -| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 | -|------|---------|-------|-----------|------| -| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass | -| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass | -| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail | -| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) | -| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — | +- 只看退出码不够:PNG 是进程自己编码写出的,「合法 PNG + 退出码 0」不等于画面有内容,像素断言是纵深防御; + 而「完全没有字体」与「非软渲染」这两种失效都是**硬失败**(panic / 报错,不产生 PNG;review 实测),运行步就红。 +- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全, + 更不证明视觉正确**——视觉靠人眼看 artifact。 +- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。 -阈值刻意落在「最弱内容也过不去、真实图有 5~7 倍余量」的位置:换主题/换字体不会误报,空白帧一定红。 -第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片 -底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。 - -**覆盖哪个平台**:只有 Linux x64(X11 + Xvfb + 软渲染)。 -- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS - runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS - 且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与 - `continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。 -- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。 - -**本地复跑**:`bash packaging/linux-smoke-docker.sh . /tmp/ull-smoke`——用 Docker(`rust:1-bookworm` + -TUNA 源装依赖)复刻同一套步骤,截图落在 `/tmp/ull-smoke/runtime-smoke.png`。诚实边界:本机容器是 -Debian 12(glibc 2.36)、官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同,容器绿只证明 -「这条通道能跑」,官方 runner 首跑仍要单独看。 - -**已知跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、 -`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。 - -**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**—— -iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」 -而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。 +**本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`(输出目录别写 `/tmp`,Git Bash 下会 +静默落进 Docker VM;脚本内部用 `cygpath -m` 归一并回显真实路径)。 ## 12. 文件清单 @@ -394,5 +369,6 @@ iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的 | `packaging/make_artifacts.py` | 打包与校验(本地 + CI 共用) | ≤400 | | `packaging/release-notes.md` | GitHub Release 说明模板(`{{VERSION}}` / `{{SUMS}}` 占位) | ≤400 | | `packaging/check_screenshot.py` | 截图非空白判定(CI 门禁 + 本地手动跑,零第三方依赖) | ≤400 | +| `packaging/ci-runtime-smoke.md` | §11 的细节:断言口径/阈值依据/失效模式/复跑 | ≤400 | | `packaging/linux-smoke-docker.sh` | 本地复跑 `runtime-smoke`(Docker + xvfb) | ≤400 | | `.github/workflows/release.yml` | tag 触发 → 矩阵构建 → Release | ≤400 | diff --git a/packaging/check_screenshot.py b/packaging/check_screenshot.py index d44250e..4f02b7f 100644 --- a/packaging/check_screenshot.py +++ b/packaging/check_screenshot.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 """截图「不是一帧空白」判定:CI 运行时冒烟的门禁脚本(零第三方依赖)。 -为什么需要它:`unlucky-launcher --screenshot` 只要进程退出码 0 就会写出 PNG—— -一张全黑/全白(窗口起来了但什么都没画、字体库缺失、软渲染器降级失败)也是「成功退出」。 -所以 CI 除了断言退出码与文件存在,还要看**像素本身有没有信息**。 +为什么需要它:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到 +「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。 +本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic +(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this +FemtoVG backend`(退出码 1)。其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计。 -三个判据(阈值依据见 `--selftest` 与 packaging/README.md「CI 运行时冒烟」): +三个判据(阈值依据见 `--selftest` 与 packaging/ci-runtime-smoke.md): - `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。 - `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。 diff --git a/packaging/ci-runtime-smoke.md b/packaging/ci-runtime-smoke.md new file mode 100644 index 0000000..c0de61b --- /dev/null +++ b/packaging/ci-runtime-smoke.md @@ -0,0 +1,78 @@ +# CI 运行时冒烟(Linux 真渲染一帧) + +`packaging/README.md` §11 是本节的索引;细节在这里。为什么拆:本节要写阈值依据、失效模式与复跑口径, +塞回 README 会撞 400 行硬门禁——**两文件都必须 ≤400 行**,任一边再增长就再拆一节。 + +「编得过」≠「跑得起来」。`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境** +运行证据:装 `xvfb`(虚拟 X 服务器)+ 软渲染依赖 + 字体,用 `xvfb-run` 把 GUI 进程拉起来,走既有的 +进程内截图通道(`--software-renderer --screenshot --data-dir `)渲出一帧 PNG,再对**像素本身**断言。 + +## 1. 三道断言 + +`packaging/check_screenshot.py` 退出码 0 才算过;artifact 名 `runtime-smoke-screenshot`。 + +1. 进程退出码 0(截图通道失败会以非 0 退出,不留半张图); +2. PNG 存在、可解析、宽高 > 0(0 字节 / 非 PNG / 路径不存在都是硬失败,脚本 exit 1); +3. **非空白**:唯一色数 / 亮度标准差 / 强边缘占比三项同时过阈值。 + +为什么不能只看退出码:`--screenshot` 的 PNG 是进程自己编码写出的,只要流程走到 `take_snapshot` 就会得到 +「合法 PNG + 退出码 0」;像素断言是**纵深防御**,守的是「进程活着、写出合法 PNG,但场景几乎没画」这一档。 +本机可复现的两种失效都是硬失败(运行步就红,不必等像素断言):①完全没有字体 → fontique panic +(退出码 101,不产生 PNG);②不是软渲染(默认 femtovg)→ `take_snapshot is not supported by this +FemtoVG backend`(退出码 1)。这两条由 M62 review 实测复现(本 mission 未自己跑这两种破坏性实验)。 +其余「渲染器出图但内容没上去」的情形本机暂无样本,属防御性设计,不宣称已复现。 + +## 2. 阈值与依据 + +`python packaging/check_screenshot.py --selftest` 可复现后三行: + +| 样本 | 唯一色数 | 亮度σ | 强边缘占比 | 判定 | +|------|---------|-------|-----------|------| +| 本机 Docker 真截图(xvfb + 软渲染,1180x760,空数据目录) | 342 | 19.45 | 0.00761 | pass | +| Windows 历史截图(M45/M52,同一界面深浅两套,1180x760) | 529~699 | 20.29~27.46 | 0.0186~0.0433 | pass | +| 人为纯白 / 纯黑(空白帧) | 1 | 0.0 | 0.0 | fail | +| 最弱内容(1 像素黑边框 + 一条中线) | 2 | 19.09 | 0.00169 | fail(卡唯一色数) | +| **阈值** | ≥64 | ≥3.0 | ≥0.001 | — | + +阈值留 **5~8 倍**余量(342/64 = 5.3×、19.45/3.0 = 6.5×、0.00761/0.001 = 7.6×):换主题/换字体不会误报, +空白帧一定红。 + +第三项用**强边缘占比**(水平相邻像素亮度差 ≥8 的比例)而不是平均亮度差:空数据目录的主界面 57% 是同一片 +底色,平均差被稀释到 0.4,强边缘只数真正的边界,跨主题稳定(同一界面深浅两套差 2 倍,仍都过线)。 + +**这道闸门的下限 ≈「至少画出一条标题栏」**(review 实测切片):只画 1180×40 标题栏的切片(174 / 35.10 / +0.02623)仍 pass,右下 400×300 的切片也 pass。所以它证明的是「场景被真实渲染过」,**不证明界面元素齐全、 +更不证明视觉正确**;视觉正确只能靠人眼看 artifact。 + +## 3. 覆盖哪个平台 + +只有 Linux x64(X11 + Xvfb + 软渲染)。 + +- **macOS**:`runtime-smoke-macos` 只在 `workflow_dispatch` 触发且 `continue-on-error: true`。官方 macOS + runner 处于 LaunchDaemon 上下文、无登录 WindowServer 会话,winit 大概率建不了窗口;本机没有 macOS + 且 worker 禁 push,这条路径**尚无实测证据**,故不拿它卡 PR 门禁。首跑定去留:绿 → 删 `if` 与 + `continue-on-error` 升常驻;红 → 错误原文进 progress,按 D26 归入 iced 重写再规划。 +- **Windows**:不新增 job——`--software-renderer --screenshot` 已在本机验收里多次人眼验证(M45/M52)。 + +## 4. 本地复跑 + +```bash +bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke +``` + +用 Docker(`rust:1-bookworm` + TUNA apt/rustup 源)复刻同一套步骤,截图落在 `<输出目录>/runtime-smoke.png`。 +**输出目录别写 `/tmp/...`**:Git Bash 里 `/tmp` 是 MSYS 虚拟路径,`docker -v` 会把它当 Linux VM 内的路径, +结果是「脚本说成功、宿主机什么都没有」。脚本对 `out_dir`/`repo_root` 统一做 `cygpath -m` 归一,并回显真实 +路径;没有 `cygpath` 的平台(Linux/macOS)按原样用。 + +诚实边界:本机容器是 Debian 12(glibc 2.36),官方 runner 是 Ubuntu 24.04(glibc 2.39),图形栈来源不同, +容器绿只证明「这条通道能跑」,官方 runner 首跑仍要单独看。 + +## 5. 已知跳过项与迁移风险 + +**跳过项**(D26 收窄,本条只做「框架无关的最小运行时冒烟」):Wayland(`weston --headless`)、 +`--screenshot-page` 子页覆盖、gsettings 主题探测回退都不在本 job 内,随 iced 重写另行规划。 + +**迁移风险**:本 job 绑死 Slint 的截图通道(`Window::take_snapshot`)。**UI 迁 iced 后必须重新验证**—— +iced 没有等价快照 API,届时要提供「离屏渲染出一帧 PNG」的等价通道,否则本 job 会静默退化成「能起进程」 +而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。 diff --git a/packaging/linux-smoke-docker.sh b/packaging/linux-smoke-docker.sh index 12794d3..9d08aff 100644 --- a/packaging/linux-smoke-docker.sh +++ b/packaging/linux-smoke-docker.sh @@ -19,7 +19,7 @@ set -euo pipefail repo_root=$(cd "${1:-.}" && pwd) -out_dir=${2:-/tmp/ull-smoke} +out_dir=${2:-ull-smoke} image=${ULL_SMOKE_IMAGE:-ull-linux-smoke:bookworm} cargo_volume=${ULL_CARGO_VOLUME:-ull-cargo-reg} target_volume=${ULL_CARGO_TARGET_VOLUME:-ull-linux-target} @@ -28,8 +28,14 @@ rustup_mirror=${ULL_RUSTUP_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn/rustup} mkdir -p "$out_dir" out_dir=$(cd "$out_dir" && pwd) +# Git Bash 里 /tmp、/d/x 都是 MSYS 虚拟路径:直接喂给 `docker -v` 会被当成 Linux VM 内的路径, +# 结果「脚本说成功、宿主机什么都没有」。有 cygpath 就统一转成 Windows 混合路径(D:/x)并回显真实位置。 +if command -v cygpath >/dev/null 2>&1; then + repo_root=$(cygpath -m "$repo_root") + out_dir=$(cygpath -m "$out_dir") +fi echo "== 仓库: $repo_root" -echo "== 输出: $out_dir" +echo "== 输出: $out_dir(截图 $out_dir/runtime-smoke.png)" # 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形) if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then @@ -72,6 +78,7 @@ MSYS_NO_PATHCONV=1 docker run --rm \ rustc --version && cargo --version && cat /etc/debian_version if [ "$ULL_SMOKE_SKIP_BUILD" != "1" ]; then # 共享热缓存卷可能喂陈旧产物(踩坑记录 N7):先清掉受检 crate 再编 + # 清不掉就算了(N7 兜底用);CI 里**没有**对应两行,别误抄进 ci.yml —— 那边每次都是干净 runner cargo clean -p unlucky-app || true cargo build --bin unlucky-launcher --locked -j 6 fi