Compare commits
3
Commits
a681ccb526
...
0aac307d92
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aac307d92 | ||
|
|
7e08e0ccc9 | ||
|
|
ea5f66cf92 |
No files matched your search
@@ -90,3 +90,83 @@ jobs:
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --workspace --release
|
||||
|
||||
# 运行时冒烟(M62):Linux 上**真渲染一帧**,补上「能编译 ≠ 能跑」的空白。
|
||||
# 上面四个 job 都只证明代码编得过、测得过,从不创建真实图形窗口;本 job 在 Xvfb 虚拟显示里
|
||||
# 启动 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
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: 无头 X 与图形栈依赖(xvfb + 软渲染 + 字体)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
xvfb xauth \
|
||||
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 \
|
||||
fonts-dejavu-core fonts-liberation2
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
# 用 debug 而不是 release:本 job 验的是「运行时渲染通路能通」,不是优化后二进制的行为;
|
||||
# release 的 lto="fat" + codegen-units=1 会把这一步拖到十几分钟却不带来额外证据。
|
||||
- run: cargo build --bin unlucky-launcher --locked
|
||||
- name: 在 Xvfb 里跑一帧并截图(退出码非 0 即失败)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
target/debug/unlucky-launcher --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白(尺寸 / 唯一色数 / 亮度σ / 强边缘占比)
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
|
||||
# macOS 槽位探针(M62):**默认不在 PR 上跑**,只有手动 dispatch 才触发。
|
||||
# 之所以是探针而不是常驻槽位:官方 macOS runner 处于 LaunchDaemon 上下文,没有登录的
|
||||
# WindowServer 会话,Slint 的 winit backend 创建窗口大概率直接失败;而 worker 禁 push,
|
||||
# 本机也没有 macOS,这条路径**尚无任何实测证据**——不拿未验证的 job 卡住主干门禁。
|
||||
# 首跑结论决定去留:绿 → 去掉 if/continue-on-error 升为常驻槽位;红 → 按 D26 归入 iced
|
||||
# 重写后再规划,并把错误原文贴进 progress 文档(不静默删 job、不吞失败原因)。
|
||||
runtime-smoke-macos:
|
||||
name: runtime smoke (macos arm64, probe)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 30
|
||||
# 探针阶段保留证据但不算流水线结论:log/artifact 里失败原因照常可见,
|
||||
# 解除条件 = 该槽位在真实 runner 上首跑通过并把本行与上面那条 if 一起删掉。
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
- run: cargo build --bin unlucky-launcher --locked
|
||||
- name: 尝试截图(macOS 没有 X 服务器,直接跑;失败即为结论)
|
||||
run: |
|
||||
set -o pipefail # 当前没有管道(死行),保留是为将来加管道时先有失败传播
|
||||
mkdir -p "$RUNNER_TEMP/smoke/data"
|
||||
target/debug/unlucky-launcher --software-renderer \
|
||||
--screenshot "$RUNNER_TEMP/smoke/runtime-smoke.png" \
|
||||
--data-dir "$RUNNER_TEMP/smoke/data"
|
||||
- name: 断言 PNG 存在且非空白
|
||||
run: python3 packaging/check_screenshot.py "$RUNNER_TEMP/smoke/runtime-smoke.png" --json
|
||||
- name: 上传截图与数据目录(失败也传,便于复盘)
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: runtime-smoke-screenshot-macos
|
||||
path: ${{ runner.temp }}/smoke/
|
||||
if-no-files-found: warn
|
||||
+23
-1
@@ -342,11 +342,33 @@ experimental / not part of this release matrix)——改一边必须同步另
|
||||
可执行文件头架构识别(真 x64 exe + 合成 PE/ELF/Mach-O 头)、以及把真 exe 的 PE 机器码改成 ARM64
|
||||
当 stub 走完命名/校验/归档全流程 —— 不假装能在 x64 上执行 arm64 二进制。
|
||||
|
||||
## 11. 文件清单
|
||||
## 11. CI 运行时冒烟(Linux 真渲染一帧)
|
||||
|
||||
**细节与依据在 [`packaging/ci-runtime-smoke.md`](ci-runtime-smoke.md)**(本节再增长也往那里写,两个文件都必须 ≤400 行)。
|
||||
|
||||
`ci.yml` 的 `runtime-smoke` job 在 `ubuntu-latest` 上补第一份**真实图形环境**运行证据:装 `xvfb` + 软渲染
|
||||
依赖 + 字体,用 `xvfb-run` 起 GUI 进程,走既有进程内截图通道渲出一帧 PNG,再对**像素本身**断言
|
||||
(退出码 0 / PNG 尺寸 / 非空白三项,artifact `runtime-smoke-screenshot`)。
|
||||
|
||||
要点三条:
|
||||
|
||||
- 只看退出码不够:PNG 是进程自己编码写出的,「合法 PNG + 退出码 0」不等于画面有内容,像素断言是纵深防御;
|
||||
而「完全没有字体」与「非软渲染」这两种失效都是**硬失败**(panic / 报错,不产生 PNG;review 实测),运行步就红。
|
||||
- 闸门下限 ≈「至少画出一条标题栏」(review 实测切片仍 pass):证明「场景被真实渲染过」,**不证明界面元素齐全,
|
||||
更不证明视觉正确**——视觉靠人眼看 artifact。
|
||||
- 只覆盖 Linux x64;macOS 是 dispatch-only 探针(无实测证据),Windows 已有本机人眼验收。
|
||||
|
||||
**本地复跑**:`bash packaging/linux-smoke-docker.sh . D:/tmp/ull-smoke`(输出目录别写 `/tmp`,Git Bash 下会
|
||||
静默落进 Docker VM;脚本内部用 `cygpath -m` 归一并回显真实路径)。
|
||||
|
||||
## 12. 文件清单
|
||||
|
||||
| 文件 | 作用 | 行数上限 |
|
||||
|------|------|---------|
|
||||
| `packaging/README.md` | 本文:矩阵/依赖/版本/合规契约 | ≤400 |
|
||||
| `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 |
|
||||
@@ -0,0 +1,317 @@
|
||||
#!/usr/bin/env python3
|
||||
"""截图「不是一帧空白」判定: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/ci-runtime-smoke.md):
|
||||
|
||||
- `unique_colors`:唯一色数。全白 / 全黑图 = 1;真实 UI 数百到上千。
|
||||
- `luma_stddev`:亮度标准差。纯色图 = 0;真实 UI 有面板/文字/渐变的明暗层次。
|
||||
- `edge_ratio`:**强边缘像素占比**——水平相邻像素对里亮度差 ≥8 的比例。纯色图 = 0;真实 UI 的文字与
|
||||
边框占千分之几到百分之几。用它而不是「平均亮度差」:空数据目录的主界面 57% 是同一片底色,
|
||||
平均差会被大片平面稀释(实测只有 0.4),而强边缘占比只数真正的边界,跨主题/跨字体都稳定。
|
||||
|
||||
只用 Python 标准库:`zlib` 解压 PNG 的 deflate 流,自己反过滤(filter 0~4)。
|
||||
不引 Pillow 是因为 CI 不该为了「看一张图」多装一套图像栈(也符合项目的依赖白名单纪律)。
|
||||
|
||||
用法::
|
||||
|
||||
python packaging/check_screenshot.py shot.png # 判定,退出码 0/1
|
||||
python packaging/check_screenshot.py shot.png --json # 机器可读(CI 日志用)
|
||||
python packaging/check_screenshot.py --selftest # 打印阈值依据的两组数值
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
||||
|
||||
# 判定阈值。两侧数值实测(M62 本机 Docker + xvfb + Windows 历史截图):
|
||||
# - 真实帧:唯一色 342~2352、亮度σ 19.45~27.46、强边缘占比 0.0076~0.0433(最弱是 Linux 空数据主界面);
|
||||
# - 人为纯色帧:1 / 0.0 / 0.0。
|
||||
# 阈值取在两者之间、贴近真实下限的 1/5~1/8:换主题/换字体/换窗口尺寸都不会误报,空白帧一定红。
|
||||
DEFAULT_MIN_UNIQUE = 64
|
||||
DEFAULT_MIN_STDDEV = 3.0
|
||||
DEFAULT_MIN_EDGE_RATIO = 0.001
|
||||
|
||||
# 「强边缘」的亮度差门槛:≥8/255 肉眼即视为一条边界;抗锯齿造成的 1~2 级抖动不算。
|
||||
EDGE_LUMA_DELTA = 8
|
||||
|
||||
# PNG 颜色类型 → 每像素通道数(调色板 3 走索引,需要 PLTE,本脚本不解)
|
||||
CHANNELS_BY_COLOR_TYPE = {0: 1, 2: 3, 4: 2, 6: 4}
|
||||
|
||||
|
||||
class PngError(Exception):
|
||||
"""PNG 结构不受支持或损坏。"""
|
||||
|
||||
|
||||
def _iter_chunks(data: bytes):
|
||||
"""按 PNG 规范切块:长度(4) + 类型(4) + 数据 + CRC(4)。"""
|
||||
offset = len(PNG_SIGNATURE)
|
||||
while offset + 12 <= len(data):
|
||||
(length,) = struct.unpack(">I", data[offset : offset + 4])
|
||||
yield data[offset + 4 : offset + 8], data[offset + 8 : offset + 8 + length]
|
||||
offset += 12 + length
|
||||
|
||||
|
||||
def _unfilter(kind: int, line: bytearray, prev: bytearray, bpp: int) -> None:
|
||||
"""就地把一条扫描线反过滤(PNG 规范 §9.2 的五个过滤类型)。"""
|
||||
if kind == 0:
|
||||
return
|
||||
if kind == 1: # Sub:加上左边
|
||||
for index in range(bpp, len(line)):
|
||||
line[index] = (line[index] + line[index - bpp]) & 0xFF
|
||||
elif kind == 2: # Up:加上正上方
|
||||
for index in range(len(line)):
|
||||
line[index] = (line[index] + prev[index]) & 0xFF
|
||||
elif kind == 3: # Average:加上 (左 + 上) // 2
|
||||
for index in range(len(line)):
|
||||
left = line[index - bpp] if index >= bpp else 0
|
||||
line[index] = (line[index] + ((left + prev[index]) >> 1)) & 0xFF
|
||||
elif kind == 4: # Paeth:加上 Paeth 预测子
|
||||
for index in range(len(line)):
|
||||
left = line[index - bpp] if index >= bpp else 0
|
||||
up = prev[index]
|
||||
upper_left = prev[index - bpp] if index >= bpp else 0
|
||||
estimate = left + up - upper_left
|
||||
dist_left = abs(estimate - left)
|
||||
dist_up = abs(estimate - up)
|
||||
dist_corner = abs(estimate - upper_left)
|
||||
if dist_left <= dist_up and dist_left <= dist_corner:
|
||||
predictor = left
|
||||
elif dist_up <= dist_corner:
|
||||
predictor = up
|
||||
else:
|
||||
predictor = upper_left
|
||||
line[index] = (line[index] + predictor) & 0xFF
|
||||
else:
|
||||
raise PngError(f"未知的行过滤类型 {kind}")
|
||||
|
||||
|
||||
def decode_png(path: Path) -> tuple[int, int, list[bytearray], int]:
|
||||
"""读 8bit 非隔行 PNG,返回 (宽, 高, 每行原始字节, 通道数)。"""
|
||||
data = path.read_bytes()
|
||||
if not data.startswith(PNG_SIGNATURE):
|
||||
raise PngError(f"{path} 不是 PNG(签名不符,可能是 0 字节文件或半截写入)")
|
||||
|
||||
width = height = depth = color_type = interlace = None
|
||||
compressed = bytearray()
|
||||
for kind, body in _iter_chunks(data):
|
||||
if kind == b"IHDR":
|
||||
width, height, depth, color_type, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
||||
elif kind == b"IDAT":
|
||||
compressed.extend(body)
|
||||
elif kind == b"IEND":
|
||||
break
|
||||
|
||||
if width is None or height is None:
|
||||
raise PngError("缺 IHDR 块")
|
||||
if interlace:
|
||||
raise PngError("不支持隔行(Adam7)PNG")
|
||||
if depth != 8:
|
||||
raise PngError(f"只支持 8bit PNG,实际 {depth}bit")
|
||||
channels = CHANNELS_BY_COLOR_TYPE.get(color_type)
|
||||
if channels is None:
|
||||
raise PngError(f"不支持的颜色类型 {color_type}(调色板 PNG 请先转成 RGB/RGBA)")
|
||||
|
||||
raw = zlib.decompress(bytes(compressed))
|
||||
stride = width * channels
|
||||
if len(raw) < (stride + 1) * height:
|
||||
raise PngError(f"像素数据不足:期望 {(stride + 1) * height} 字节,实际 {len(raw)}")
|
||||
|
||||
rows: list[bytearray] = []
|
||||
previous = bytearray(stride)
|
||||
offset = 0
|
||||
for _ in range(height):
|
||||
kind = raw[offset]
|
||||
offset += 1
|
||||
line = bytearray(raw[offset : offset + stride])
|
||||
offset += stride
|
||||
_unfilter(kind, line, previous, channels)
|
||||
rows.append(line)
|
||||
previous = line
|
||||
return width, height, rows, channels
|
||||
|
||||
|
||||
def measure(width: int, height: int, rows: list[bytearray], channels: int) -> dict:
|
||||
"""统计「这帧图里有没有信息」的三项指标。"""
|
||||
colors: Counter[int] = Counter()
|
||||
luma_sum = 0
|
||||
luma_square_sum = 0
|
||||
strong_edges = 0
|
||||
pair_count = 0
|
||||
|
||||
for row in rows:
|
||||
previous_luma = None
|
||||
for x in range(width):
|
||||
base = x * channels
|
||||
if channels >= 3:
|
||||
red, green, blue = row[base], row[base + 1], row[base + 2]
|
||||
else:
|
||||
red = green = blue = row[base]
|
||||
colors[(red << 16) | (green << 8) | blue] += 1
|
||||
# 整数亮度(ITU-R BT.601 系数),避免浮点误差影响阈值比较
|
||||
luma = (red * 299 + green * 587 + blue * 114) // 1000
|
||||
luma_sum += luma
|
||||
luma_square_sum += luma * luma
|
||||
if previous_luma is not None:
|
||||
pair_count += 1
|
||||
if abs(luma - previous_luma) >= EDGE_LUMA_DELTA:
|
||||
strong_edges += 1
|
||||
previous_luma = luma
|
||||
|
||||
total = width * height
|
||||
mean = luma_sum / total if total else 0.0
|
||||
variance = max(luma_square_sum / total - mean * mean, 0.0) if total else 0.0
|
||||
top_color, top_count = colors.most_common(1)[0] if colors else (0, 0)
|
||||
return {
|
||||
"width": width,
|
||||
"height": height,
|
||||
"pixels": total,
|
||||
"unique_colors": len(colors),
|
||||
"top_color": f"#{top_color:06x}",
|
||||
"top_color_share": round(top_count / total, 4) if total else 0.0,
|
||||
"luma_mean": round(mean, 2),
|
||||
"luma_stddev": round(math.sqrt(variance), 2),
|
||||
"edge_ratio": round(strong_edges / pair_count, 5) if pair_count else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def verdict(
|
||||
stats: dict, min_unique: int, min_stddev: float, min_edge_ratio: float
|
||||
) -> list[str]:
|
||||
"""把指标折成「失败原因清单」;空清单 = 通过。"""
|
||||
problems = []
|
||||
if stats["pixels"] <= 0:
|
||||
problems.append("图像尺寸为 0(宽或高是 0)")
|
||||
return problems
|
||||
if stats["unique_colors"] < min_unique:
|
||||
problems.append(
|
||||
f"唯一色数 {stats['unique_colors']} < 阈值 {min_unique}(像纯色/空白帧)"
|
||||
)
|
||||
if stats["luma_stddev"] < min_stddev:
|
||||
problems.append(
|
||||
f"亮度标准差 {stats['luma_stddev']} < 阈值 {min_stddev}(整帧明暗无层次)"
|
||||
)
|
||||
if stats["edge_ratio"] < min_edge_ratio:
|
||||
problems.append(
|
||||
f"强边缘占比 {stats['edge_ratio']} < 阈值 {min_edge_ratio}(没有边界/文字的迹象)"
|
||||
)
|
||||
return problems
|
||||
|
||||
|
||||
def encode_png(width: int, height: int, rgb_row) -> bytes:
|
||||
"""最小 PNG 编码器(8bit RGB、filter 0、zlib 压缩)——只给 `--selftest` 造样图。"""
|
||||
raw = bytearray()
|
||||
for y in range(height):
|
||||
raw.append(0)
|
||||
raw.extend(rgb_row(y))
|
||||
out = bytearray(PNG_SIGNATURE)
|
||||
for kind, body in (
|
||||
(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)),
|
||||
(b"IDAT", zlib.compress(bytes(raw), 6)),
|
||||
(b"IEND", b""),
|
||||
):
|
||||
out.extend(struct.pack(">I", len(body)))
|
||||
out.extend(kind)
|
||||
out.extend(body)
|
||||
out.extend(struct.pack(">I", zlib.crc32(kind + body) & 0xFFFFFFFF))
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def selftest() -> int:
|
||||
"""造两张对照样图跑一遍,打印阈值依据的两组数值。
|
||||
|
||||
- 全白 / 全黑:模拟「窗口起来了但一个像素都没画」的空白帧;
|
||||
- 一像素黑边框 + 中部一条横线:最弱的「有内容」下限(远弱于真实 UI)。
|
||||
"""
|
||||
width, height = 1180, 760
|
||||
cases = {
|
||||
"flat-white": lambda y: b"\xff" * (width * 3),
|
||||
"flat-black": lambda y: b"\x00" * (width * 3),
|
||||
"border-line": lambda y: b"".join(
|
||||
b"\x00\x00\x00"
|
||||
if (y in (0, height - 1) or x in (0, width - 1) or y == height // 2)
|
||||
else b"\xff\xff\xff"
|
||||
for x in range(width)
|
||||
),
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
for name, describe in cases.items():
|
||||
path = Path(tmp) / f"{name}.png"
|
||||
path.write_bytes(encode_png(width, height, describe))
|
||||
stats = measure(*decode_png(path))
|
||||
failed = verdict(
|
||||
stats, DEFAULT_MIN_UNIQUE, DEFAULT_MIN_STDDEV, DEFAULT_MIN_EDGE_RATIO
|
||||
)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"case": name,
|
||||
"unique_colors": stats["unique_colors"],
|
||||
"luma_stddev": stats["luma_stddev"],
|
||||
"edge_ratio": stats["edge_ratio"],
|
||||
"verdict": "fail" if failed else "pass",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
parser = argparse.ArgumentParser(description="截图非空白判定(CI 运行时冒烟门禁)")
|
||||
parser.add_argument("png", nargs="?", type=Path, help="待判定的 PNG 路径")
|
||||
parser.add_argument("--json", action="store_true", help="以 JSON 输出统计结果")
|
||||
parser.add_argument("--min-unique", type=int, default=DEFAULT_MIN_UNIQUE, help="唯一色数下限")
|
||||
parser.add_argument("--min-stddev", type=float, default=DEFAULT_MIN_STDDEV, help="亮度标准差下限")
|
||||
parser.add_argument(
|
||||
"--min-edge-ratio", type=float, default=DEFAULT_MIN_EDGE_RATIO, help="强边缘占比下限"
|
||||
)
|
||||
parser.add_argument("--selftest", action="store_true", help="打印阈值依据的对照数值后退出")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.selftest:
|
||||
return selftest()
|
||||
if args.png is None:
|
||||
parser.error("需要给出 PNG 路径(或 --selftest)")
|
||||
|
||||
try:
|
||||
stats = measure(*decode_png(args.png))
|
||||
except (PngError, OSError, zlib.error) as error:
|
||||
print(f"[fail] 读不出截图 {args.png}: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
problems = verdict(stats, args.min_unique, args.min_stddev, args.min_edge_ratio)
|
||||
if args.json:
|
||||
print(json.dumps({**stats, "problems": problems}, ensure_ascii=False))
|
||||
else:
|
||||
print(
|
||||
f"{args.png}: {stats['width']}x{stats['height']} "
|
||||
f"唯一色 {stats['unique_colors']} 亮度σ {stats['luma_stddev']} "
|
||||
f"强边缘 {stats['edge_ratio']} 主色 {stats['top_color']}"
|
||||
f"({stats['top_color_share'] * 100:.1f}%)"
|
||||
)
|
||||
if problems:
|
||||
for problem in problems:
|
||||
print(f"[fail] {problem}", file=sys.stderr)
|
||||
return 1
|
||||
print("[ok] 截图非空白")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(sys.argv[1:]))
|
||||
@@ -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 <png> --data-dir <tmp>`)渲出一帧 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 会静默退化成「能起进程」
|
||||
而不是「能出图」(它本来也只证明「能出图」,不证明视觉正确)。
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# 本地复跑 `.github/workflows/ci.yml` 的 `runtime-smoke` job:Linux + Xvfb + 软渲染截图。
|
||||
#
|
||||
# 为什么要有这条脚本:CI 的 runtime-smoke 在 GitHub 的 ubuntu-latest 上跑,
|
||||
# 但「改动这个 job 的人」需要在没有 Linux 桌面的开发机上先自证一遍——否则改的就是没人验过的 YAML。
|
||||
# 本脚本在 Docker 里复刻 job 的步骤(同样的 apt 依赖、同样的构建、同样的 xvfb-run 命令),
|
||||
# 并把截图与判定结果留在宿主目录,供人眼与 `check_screenshot.py` 复核。
|
||||
#
|
||||
# 用法:
|
||||
# bash packaging/linux-smoke-docker.sh [仓库根目录] [输出目录]
|
||||
# ULL_SMOKE_SKIP_BUILD=1 bash packaging/linux-smoke-docker.sh # 复用缓存的二进制
|
||||
#
|
||||
# 说明(诚实边界):
|
||||
# - 基础镜像 `rust:1-bookworm` 是 **Debian 12(glibc 2.36)**,官方 runner 是 **Ubuntu 24.04(glibc 2.39)**。
|
||||
# 两者图形栈(X11/xvfb/Mesa 软渲染)来源不同,因此本脚本证明的是「Linux 上这条通道能真跑出图」,
|
||||
# 不能代替官方 runner 的首跑验证。
|
||||
# - 国内网络慢时可换 TUNA 镜像:脚本默认就用 TUNA 的 Debian 源(`ULL_APT_MIRROR` 可覆盖)。
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
repo_root=$(cd "${1:-.}" && pwd)
|
||||
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}
|
||||
apt_mirror=${ULL_APT_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}
|
||||
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(截图 $out_dir/runtime-smoke.png)"
|
||||
|
||||
# 1) 构建镜像:rust 工具链 + xvfb + 图形栈编译依赖 + 字体(软渲染器要从系统字体库取字形)
|
||||
if ! docker image inspect "$image" >/dev/null 2>&1 || [ "${ULL_SMOKE_REBUILD_IMAGE:-0}" = "1" ]; then
|
||||
context=$(mktemp -d)
|
||||
trap 'rm -rf "$context"' EXIT
|
||||
cat >"$context/Dockerfile" <<EOF
|
||||
FROM rust:1-bookworm
|
||||
# 工具链组件(rustfmt/clippy)在 rust-toolchain.toml 里是必备项,rustup 会去补齐;
|
||||
# 默认 dist 服务器在国内会挂成「rustc 0.6% CPU 睡 12 分钟」的假死(M62 实测),所以走 TUNA。
|
||||
ENV RUSTUP_DIST_SERVER=$rustup_mirror \\
|
||||
RUSTUP_UPDATE_ROOT=$rustup_mirror/rustup
|
||||
RUN sed -i 's|http://deb.debian.org|$apt_mirror|g' /etc/apt/sources.list.d/debian.sources \\
|
||||
&& apt-get update \\
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \\
|
||||
xvfb xauth python3 \\
|
||||
libxkbcommon-dev libxkbcommon-x11-0 libfontconfig1-dev \\
|
||||
libgl1-mesa-dev libegl1-mesa-dev libwayland-dev libxcb1-dev \\
|
||||
libx11-6 libxcursor1 libxrandr2 libxi6 \\
|
||||
fonts-dejavu-core fonts-liberation2 \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
RUN rustup component add rustfmt clippy && rustup toolchain list
|
||||
WORKDIR /src
|
||||
EOF
|
||||
docker build -t "$image" "$context"
|
||||
fi
|
||||
|
||||
# 2) 跑容器:构建 → xvfb 截图 → 像素判定
|
||||
# 注意缓存卷要挂到 `registry` 子目录:直接挂 /usr/local/cargo 会把镜像里的 `bin/`(cargo 本体)盖掉,
|
||||
# 变成 `cargo: command not found`(M62 实测踩到)。
|
||||
MSYS_NO_PATHCONV=1 docker run --rm \
|
||||
-v "$repo_root:/src" \
|
||||
-v "$cargo_volume:/usr/local/cargo/registry" \
|
||||
-v "$target_volume:/target" \
|
||||
-v "$out_dir:/out" \
|
||||
-w /src \
|
||||
-e CARGO_TARGET_DIR=/target \
|
||||
-e ULL_SMOKE_SKIP_BUILD="${ULL_SMOKE_SKIP_BUILD:-0}" \
|
||||
-e CARGO_TERM_COLOR=never \
|
||||
"$image" bash -euo pipefail -c '
|
||||
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
|
||||
mkdir -p /out/data
|
||||
set +e
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
/target/debug/unlucky-launcher --software-renderer \
|
||||
--screenshot /out/runtime-smoke.png --data-dir /out/data
|
||||
code=$?
|
||||
set -e
|
||||
echo "exit=$code"
|
||||
python3 packaging/check_screenshot.py /out/runtime-smoke.png
|
||||
exit $code
|
||||
'
|
||||
|
||||
echo "== 截图与数据目录已留在 $out_dir"
|
||||
Reference in new issue
Block a user