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)。
This commit is contained in:
worker-updatee2e authored and AskaEth committed 2026-11-08 11:12:10 +08:00
1 parent a14b9e17c1
commit 24826c1a7c
18 files changed
+1133 -44

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
//! 更新通道端到端验收(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()
);
}