Files
Unlucky-Launcher/crates/cli/tests/update_e2e_test.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

196 lines
9.5 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):**临时数据根 + 本地更新源 + 真形状的清单与产物**,
//! 从 CLI 入口走完「发现新版本 → 下载 → 长度/SHA256 双检 → 就位」。
//!
//! 一键跑(**必须带 feature**;tower 要求验收落在 release profile 上,debug 与 release 的
//! `debug_assertions`、优化与 panic 行为都不同,用 debug 验收等于没验真实产物):
//!
//! ```text
//! cargo test --release -p unlucky-cli --features update-test-source \
//! --test update_e2e_test --test update_e2e_tamper_test
//! ```
//!
//! (两个测试目标各跑一半:本文件是正路 + 幂等,`update_e2e_tamper_test` 是篡改/截断。)
//!
//! 不带 `--features update-test-source` 时整个目标 `#![cfg]` 掉(**0 个用例**,不是绿):测试通道
//! 在那种构建里根本不存在,「环境变量设满也走 GitHub」由 `crates/core` 的
//! `the_test_channel_is_a_compile_time_opt_in` 在**不带 feature 的跑法**下守着。
//!
//! 为什么需要 feature:`ULL_UPDATE_DIR` 曾经只在 `debug_assertions` 下生效,于是发布产物(release)
//! **没法**指向本地源,这条链在 release 上从来没有端到端证据。M169 把判据换成编译期 feature
//! (默认关 ⇒ 官方二进制里没有这条路径),验收构建显式打开它。
//!
//! # 现场
//!
//! 见 `tests/support/update_e2e.rs`:数据根 / 本地源 / 安装目录三份临时目录,产物是现场构建出来的
//! `ull.exe` 的**前 1 MiB**(真 PE 头、真字节;整份 ~400 MB,测材太重在 `PAYLOAD_LIMIT` 的注释里
//! 写了原因),清单与 `packaging/tests/manifest.golden.json` 同字段。**`--dest` 永远指向临时副本**
//! ——这个文件里没有一处会替换本进程的 exe。
//!
//! # 抓得住什么 / 抓不住什么
//!
//! | 本套断言 | 抓得住 | 抓不住 |
//! |---|---|---|
//! | 清单 → 选版本 → 下载 → 双检 → 就位整环跑通 | 任一环断了(字段拼错、渠道不符、没下、没校验、没搬)都会红 | 真 GitHub Release 的下载链路(本套一个字节不出网) |
//! | 清单 `sha256` 与盘上字节不符 ⇒ 拒绝 + 目标不动 | 「校验和判据被绕过/写成恒真」;篡改包体或篡改清单都能抓(见 `update_e2e_tamper_test`) | 「CDN 换了内容**而清单里的 sha 也一起换**」——这条路上没有可信根,只能靠 GitHub 侧 |
//! | 清单 `size` 与盘上长度不符 ⇒ 拒绝 | 截断/拼接(长度判据先跑,比摘要便宜) | 同上:清单自己撒谎时没有任何判据能发现 |
//! | 重复 `apply` 后目标字节稳定、暂存无残留 | 「第二次把 dest 写坏 / 暂存留下垃圾」 | 「版本号真的前进了」——`current` 取的是**编译期**版本,不读 dest 里的 exe |
//! | 失败后目标仍是旧字节、无 `.ullold-*` | 「校验失败却已经动过目标」 | 跨卷(数据根与 exe 不同盘)时的替换:`rename` 不能跨卷,那条路没验 |
//!
//! # 不验的部分(如实划界)
//!
//! 1. **真实 GitHub Release 下载**:本套一个字节都不出网(源是本地目录)。`releases/latest` 的
//! 真实响应、镜像/gh-proxy 回退、CDN 的 ETag/断点续传都属于真网络验收。
//! 2. **GUI 的点击与重启替换**:设置页「下载并安装」、提示条、`RestartNow` 之后的进程重启不在
//! 自动化里(替换自身 exe 需要真机,且要人工确认新版本能起来)。
//! 3. **按版本号忽略**:`presenter::host::update_state` 的 `skipped` 是会话级行为,有单测
//! (`update_session_tests.rs`),CLI 侧没有对应开关,所以这里不覆盖。
//! 4. **`--dest` 之外的自替换**(替换本进程 exe):`tests/support/update_e2e.rs` 的文件头写了为什么
//! 这几条用例永远带 `--dest`,以及「去掉它会真的改名换掉测试二进制」这件事。
#![cfg(feature = "update-test-source")]
#[path = "support/mod.rs"]
mod support;
use support::update_e2e::{channel_lock, Channel, Fixture, INSTALLED};
use unlucky_core::update::Target;
/// 发现新版本 → 下载 → 双检 → 就位:整环从 CLI 入口跑一遍,并核对每一步的产物。
#[test]
fn discovers_downloads_and_installs_from_the_local_channel() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-happy");
Channel::enable(fx.dist.path());
fx.publish_good();
// 1) 发现新版本:源是本地目录,脚本能看出这一份结论来自测试通道。
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "check 应成功:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "available", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(data["current"], env!("CARGO_PKG_VERSION"));
assert_eq!(
data["size"].as_u64(),
Some(fx.payload.len() as u64),
"清单声明的字节数 = 产物真实字节数:{json}"
);
assert_eq!(data["target"], Target::current().as_str());
assert_eq!(data["notes"], "修掉启动崩溃;实例页加载更快");
assert_eq!(data["forced"], false);
assert_eq!(data["test_source"], true, "测试通道必须在输出里点名");
assert_eq!(
data["source"],
format!("file:{}", fx.dist.path().display()),
"源必须是本地目录(不是 GitHub)"
);
// 2) 人读输出也点名测试通道:人看的那份不能比 JSON 少说一句。
let (code, human, _) = fx.run(&["check"]);
assert_eq!(code, 0);
assert!(human.contains("发现新版本"), "{human}");
assert!(
human.contains("测试通道生效"),
"人读输出要点名「这一份结论来自本地夹具」:{human}"
);
// 3) 下载 → 长度/SHA256 双检 → 就位。
let (code, json) = fx.apply();
assert_eq!(code, 0, "apply 应成功:{json}");
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "installed", "{json}");
assert_eq!(data["version"], fx.version.as_str());
assert_eq!(
data["bytes"].as_u64(),
Some(fx.payload.len() as u64),
"{json}"
);
assert_eq!(data["path"], fx.dest.display().to_string().as_str());
assert_eq!(data["test_source"], true);
let backup = data["backup"]
.as_str()
.expect("旧版本应留备份(回滚用)")
.to_string();
// 4) 盘上事实:目标 = 产物字节,备份 = 旧字节,暂存无残留。
assert_eq!(
fx.installed_bytes(),
fx.payload,
"就位的必须就是清单里那一份字节"
);
assert_eq!(
std::fs::read(&backup).expect("读备份"),
INSTALLED,
"旧版本要在备份里,不能凭空消失"
);
assert!(
fx.staging_leftovers().is_empty(),
"暂存目录里不许留 .ullupd:{:?}",
fx.staging_leftovers()
);
// 5) 装完再查:**仍然报有更新**——因为 current 取的是编译期版本,不读 dest 里的 exe。
// 这不是 bug(真实场景里替换完就会重启),但脚本作者必须知道这一条。
let (code, out, _) = fx.run(&["check", "--json"]);
assert_eq!(code, 0);
let json = support::parse_json(&out);
assert_eq!(support::update_e2e::payload(&json)["status"], "available");
}
/// 没有清单的本地渠道 = 「还没有任何发布」,不是错误、也不是「已是最新」以外的胡话。
///
/// 顺带把「源真的被换成本地目录」在没有产物的情况下也验一遍(全程离线)。
#[test]
fn an_empty_local_channel_is_up_to_date_not_an_error() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-empty");
Channel::enable(fx.dist.path());
let (code, out, err) = fx.run(&["check", "--json"]);
assert_eq!(code, 0, "缺清单不该是错误:{err}");
let json = support::parse_json(&out);
let data = support::update_e2e::payload(&json);
assert_eq!(data["status"], "up_to_date", "{json}");
assert_eq!(data["latest"], serde_json::Value::Null);
assert_eq!(data["test_source"], true);
}
/// 重复 `apply` 的**终态**与一次相同:目标字节不变、暂存不留垃圾。
///
/// 如实记两条:`apply` 每次都留一个备份(CLI 从不删,等下一次启动/人工清理),而且第二次之所以
/// 还会「装上」,是因为 `current` 是编译期版本——重复执行不是空操作,只是结果幂等。
#[test]
fn applying_twice_leaves_the_same_target_bytes() {
let _serial = channel_lock();
let _restore = Channel::capture();
let fx = Fixture::new("e2e-twice");
Channel::enable(fx.dist.path());
fx.publish_good();
for round in 1..=2 {
let (code, json) = fx.apply();
assert_eq!(code, 0, "第 {round} 次 apply 应成功:{json}");
assert_eq!(support::update_e2e::payload(&json)["status"], "installed");
assert_eq!(
fx.installed_bytes(),
fx.payload,
"第 {round} 次 apply 之后目标字节必须仍是产物"
);
assert!(
fx.staging_leftovers().is_empty(),
"第 {round} 次 apply 之后暂存目录还有 {:?}",
fx.staging_leftovers()
);
}
assert_eq!(
fx.backups().len(),
2,
"每次 apply 留一个备份(CLI 不删):{:?}",
fx.backups()
);
}