Files
Unlucky-Launcher/crates/cli/tests/support/update_e2e.rs
T
worker-updatee2e 24826c1a7c feat(m169): 更新通道端到端验收 —— 本地源改成编译期 feature,补一条可一键跑的 E2E + CI 守门
§6 欠账:更新链路的 GUI 端从来没验过,而 M167 发现它根本没法在本机验——`ULL_UPDATE_DIR` 只在
`debug_assertions` 下生效,发布产物却是 release;`apply` 又会替换本进程 exe,于是没人敢跑。

判据换档(tower 定案 B):`cfg!(debug_assertions)` → 编译期 cargo feature `update-test-source`
(core 定义,cli/presenter/app-iced 链式转发,**默认关**)。带 feature 的构建里还要两档环境变量
(`ULL_UPDATE_DIR` + `ULL_UPDATE_TEST_SOURCE=1`),只给路径什么都不会发生。官方发布二进制里那段
分支根本编不进来——README「release builds do not contain that path at all」这句因此继续为真。

- core: `job::default_source` 的本地源分支整段 `#[cfg(feature = "update-test-source")]`;判据抽成
  纯函数 `test_source_root_from` 并逐条单测(半开一律关、空路径、承认值只认 `1`);
  `the_test_channel_is_a_compile_time_opt_in` 在**两种构建下各断各的**(带 feature ⇒ 换源;
  不带 ⇒ 环境变量设满也走 GitHub)。
- cli: `update check/apply` 的 `--json` 增加 `test_source` 字段(判据派生自实际用了哪个源,不是回读
  环境变量),人读输出多一行点名;`help.rs` 的 `ULL_UPDATE_DIR` 说明按新判据重写。
- 验收:`crates/cli/tests/update_e2e_test.rs` + `update_e2e_tamper_test.rs` + 共用夹具
  `tests/support/update_e2e.rs`。临时数据根 + 本地源 + 真形状清单(与 `manifest.golden.json` 同
  字段)+ 真 PE 产物(现场构建的 ull.exe 前 1 MiB,理由写在 `PAYLOAD_LIMIT`)。断言覆盖:发现
  新版本(source=file:… / test_source=true / size / notes)、下载 → 双检 → 就位(dest 字节、旧字节
  进备份、暂存无残留)、重复 apply 的幂等终态、篡改 sha256(清单撒谎 / 包体被换)与截断都必须
  拒绝且不动现有安装、修复后能重装。**永远带 `--dest`**:不带会真的改名换掉测试二进制。
- 守门:`packaging/check_test_source_off.py`(stdlib-only)扫 `.github/workflows/**`、
  `packaging/*.py|*.sh` 与各 `Cargo.toml` 的 `default`,任何一处打开 feature(或 `--all-features`)
  就红;`--self-test` 注入 5 条真实回归证明每条都会红。CI 挂在 release-guard.yml 两步。

验证(本机):core update 单测 80 绿 / 带 feature 6 绿;E2E debug 6 绿;
`cargo test --release --features update-test-source` 6 绿(tower 要求 release 档验收);
`cargo test -p unlucky-cli -p unlucky-presenter` 全绿;守门 self-test + 正式扫描绿(真注入后红)。

已知没验:真 GitHub Release 下载、GUI 点击与重启替换(人工);跨卷替换(另开 finding)。
2026-11-08 11:12:10 +08:00

287 lines
11 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 更新通道端到端验收的夹具(M169):临时现场、真形状清单、测试通道的环境变量守卫。
//!
//! 两个测试目标共用(`update_e2e_test` 正路 / 幂等,`update_e2e_tamper_test` 篡改);每个目标只
//! 用到其中一部分,所以与 `support/mod.rs` 一样放开 dead_code。
//!
//! 整个夹具只在 `update-test-source` feature 下真的被用到:那两个测试目标自己 `#![cfg]` 掉,
//! 这里只是跟着编译(不跑)。判据(feature + 两档环境变量)在 `core::update::job`。
//!
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
#![allow(dead_code)]
use std::path::{Path, PathBuf};
use unlucky_core::update::{HashAlgo, OsFamily, Target, UPDATE_DIR_ENV, UPDATE_TEST_SOURCE_ENV};
/// 假装的「已安装版本」字节:与产物**必须不同**,否则「目标没被动过」这类断言会假绿。
pub const INSTALLED: &[u8] = b"installed 0.2.3 build (pretend)";
/// 产物体量上限(1 MiB)。
///
/// 现场构建出来的 `ull.exe` 是 **~400 MB**(debug + 全套 reqwest/rmcp/axum);拿整份当过测材,
/// 光 sha256 + 拷贝就要几十秒,验收会变成「谁都不想跑」。所以截取**前 1 MiB**:PE 头与头几节都在,
/// 「这是真 exe 的字节」这一点保留,代价是「装上去能不能启动」不再由它证明——那一跳本来就要人工
/// (见报告「不验的部分」)。清单里的 `size` / `sha256` 按这份截短后的真实字节算,仍然自洽。
const PAYLOAD_LIMIT: usize = 1024 * 1024;
/// 清单里的更新说明(断言它会原样出现在 `check --json` 里)。
const NOTES: &str = "修掉启动崩溃;实例页加载更快";
/// SHA-256 按真实字节算(与双检同一条实现:`core::download::verify::digest_bytes`)。
pub fn sha256(bytes: &[u8]) -> String {
unlucky_core::download::verify::digest_bytes(HashAlgo::Sha256, bytes)
}
/// 清单版本:**比编译期版本严格大一档次版本**。
///
/// 不能写死 `0.2.4`:workspace 版本一升,用例就变成「已是最新」的假红。次版本 +1 恒大于当前
/// 版本,且仍在同一 major 内(不会意外触发 `min_supported` 那条判据)。
pub fn next_version(current: &str) -> String {
let mut parts = current.split('.');
let major = parts.next().unwrap_or("0");
let minor: u64 = parts
.next()
.unwrap_or("0")
.parse()
.unwrap_or_else(|_| panic!("编译期版本号应是三段式:{current}"));
assert!(parts.next().is_some(), "编译期版本号应是三段式:{current}");
format!("{major}.{}.0", minor + 1)
}
/// 真实产物命名(D49):`unlucky-launcher_<ver>_windows_x64.exe`。
///
/// 拿 `Target::as_str()`(`win-x64`)直接拼会得到一个世上不存在的文件名,清单那一栏也就
/// 与真实发布对不上——「真形状」的意义正在这里。
fn asset_name(version: &str) -> String {
let target = Target::current();
let os = match target.os {
OsFamily::Windows => "windows",
OsFamily::MacOs => "macos",
OsFamily::Linux => "linux",
};
format!(
"unlucky-launcher_{version}_{os}_{}.exe",
target.arch.as_str()
)
}
/// 一份与 `packaging/tests/manifest.golden.json` **同字段**的清单。
///
/// 字段一个不多一个不少:core 的 `deny_unknown_fields` 会把多余字段当清单损坏拒掉,所以这条
/// 同时也是「清单形状与真实生成器(`packaging/make_manifest.py`)对齐」的断言。
fn manifest(version: &str, asset: &str, size: u64, sha256: &str) -> String {
format!(
r#"{{
"channel": "stable",
"releases": [
{{
"version": "{version}",
"channel": "stable",
"released_at": "2026-01-02T03:04:05Z",
"notes": "{NOTES}",
"force": false,
"min_supported": "0.1.0",
"artifacts": [
{{
"target": "{}",
"format": "portable-exe",
"url": "{asset}",
"size": {size},
"sha256": "{sha256}"
}}
]
}}
]
}}"#,
Target::current().as_str()
)
}
/// 进程环境是共享的(同一测试进程里各用例并行):动环境变量的用例都拿这把锁串起来。
pub fn channel_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
}
/// 两个测试通道变量的现场:构造时记住原值,`Drop` 还原(用例 panic 也还原)。
pub struct Channel {
dir: Option<std::ffi::OsString>,
ack: Option<std::ffi::OsString>,
}
impl Channel {
pub fn capture() -> Self {
Self {
dir: std::env::var_os(UPDATE_DIR_ENV),
ack: std::env::var_os(UPDATE_TEST_SOURCE_ENV),
}
}
/// 测试通道开启:两档环境变量全给(二进制还得带 `update-test-source` feature;只给路径不会被
/// 认,判据见 `core::update::job`)。
pub fn enable(root: &Path) {
std::env::set_var(UPDATE_DIR_ENV, root);
std::env::set_var(UPDATE_TEST_SOURCE_ENV, "1");
}
}
impl Drop for Channel {
fn drop(&mut self) {
for (key, value) in [
(UPDATE_DIR_ENV, self.dir.clone()),
(UPDATE_TEST_SOURCE_ENV, self.ack.clone()),
] {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
/// `--json` 信封 → 命令载荷。
///
/// 成功信封是 `{"ok":true,"data":{…},"warnings":[…]}`,失败信封是 `{"ok":false,"error":{…}}`
/// (没有 `data`)。所以「取载荷」= 有 `data` 取 `data`,否则原样返回——失败用例照样能看
/// `error.code` / `error.detail.kind`。
pub fn payload(json: &serde_json::Value) -> &serde_json::Value {
json.get("data").unwrap_or(json)
}
/// 目录里的文件名(读不到就当空)。
fn list_dir(dir: &Path) -> Vec<String> {
match std::fs::read_dir(dir) {
Ok(entries) => entries
.filter_map(Result::ok)
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect(),
Err(_) => Vec::new(),
}
}
/// 一次验收的全部现场:
///
/// ```text
/// <temp>/<tag>-data/ --data-dir(自定义模式 ⇒ <data>/.ULL 与 <data>/.minecraft)
/// <temp>/<tag>-dist/ ULL_UPDATE_DIR(stable.json + 产物)
/// <temp>/<tag>-install/ --dest 所在目录(**临时副本**,不是本进程的 exe)
/// ```
///
/// 三个 `TempDir` 都要活到用例结束(`Fixture` 持有它们)。
pub struct Fixture {
pub data: super::TempDir,
pub dist: super::TempDir,
install: super::TempDir,
/// 产物字节 = 现场构建出来的 `ull.exe` 的前 1 MiB(真 PE 头,理由见 [`PAYLOAD_LIMIT`])。
pub payload: Vec<u8>,
/// 清单里的版本号(= 编译期版本次版本 +1)。
pub version: String,
/// 产物文件名(清单 `url` 与盘上文件同名)。
pub asset: String,
/// 替换目标(安装目录里的副本)。
pub dest: PathBuf,
}
/// 现场构建出来的 `ull.exe` 的前 [`PAYLOAD_LIMIT`] 字节(读一次,整份不超过 1 MiB)。
///
/// 为什么不用占位字符串:这是**发布矩阵里那个单文件 exe** 的真字节,PE 头、节表、体积都对得上;
/// 为什么截短:整份 ~400 MB,测材太重(理由见 [`PAYLOAD_LIMIT`])。
fn payload_bytes() -> Vec<u8> {
let path = env!("CARGO_BIN_EXE_ull");
let mut payload = std::fs::read(path).expect("读构建出来的 ull.exe");
payload.truncate(PAYLOAD_LIMIT);
assert!(
payload.len() >= 64 * 1024,
"产物要像真的(至少 64 KiB),实际 {} 字节",
payload.len()
);
if cfg!(windows) {
assert!(
payload.starts_with(b"MZ"),
"Windows 产物得是 PE 映像(前两个字节 MZ),否则「真形状」这句话就没意义"
);
}
payload
}
impl Fixture {
pub fn new(tag: &str) -> Self {
let payload = payload_bytes();
let version = next_version(env!("CARGO_PKG_VERSION"));
let install = super::TempDir::new(&format!("{tag}-install"));
let dest = install.path().join("unlucky-launcher.exe");
std::fs::write(&dest, INSTALLED).expect("写旧版本");
Self {
data: super::TempDir::new(&format!("{tag}-data")),
dist: super::TempDir::new(&format!("{tag}-dist")),
install,
asset: asset_name(&version),
payload,
version,
dest,
}
}
/// 发一版:清单 + 产物都按 `payload` 的真实长度与摘要。
pub fn publish_good(&self) {
self.publish(&sha256(&self.payload), self.payload.len() as u64);
}
/// 发一版,摘要/长度由调用方给(篡改用例用)。
pub fn publish(&self, sha256: &str, size: u64) {
std::fs::write(self.dist.path().join(&self.asset), &self.payload).expect("写产物");
std::fs::write(
self.dist.path().join("stable.json"),
manifest(&self.version, &self.asset, size, sha256),
)
.expect("写清单");
}
/// 跑一条 `update` 子命令:`--data-dir` 指向临时数据根(与 GUI 一样由数据根派生暂存目录)。
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
let mut full = vec!["update"];
full.extend_from_slice(args);
let data_dir = self.data.arg();
full.push("--data-dir");
full.push(data_dir.as_str());
super::run_cli(&full)
}
/// 跑 `apply --dest <副本> --json`(**永远**带 `--dest`,见文件头)。
pub fn apply(&self) -> (i32, serde_json::Value) {
let dest = self.dest.display().to_string();
let (code, out, _) = self.run(&["apply", "--dest", &dest, "--json"]);
(code, super::parse_json(&out))
}
/// 暂存目录(`<数据根>/.ULL/cache/update/staging`,与 `core::update::staging_dir` 同形)。
pub fn staging(&self) -> PathBuf {
self.data
.path()
.join(".ULL")
.join("cache")
.join("update")
.join("staging")
}
/// 暂存目录里还留着什么(`StagedPackage` 落地/被搬走之后应为空)。
pub fn staging_leftovers(&self) -> Vec<String> {
list_dir(&self.staging())
}
/// 安装目录里的备份(`.unlucky-launcher.exe.ullold-*`)。
pub fn backups(&self) -> Vec<String> {
list_dir(self.install.path())
.into_iter()
.filter(|name| name.contains(".ullold-"))
.collect()
}
/// 目标文件当前字节。
pub fn installed_bytes(&self) -> Vec<u8> {
std::fs::read(&self.dest).expect("读目标")
}
}