Files
Unlucky-Launcher/crates/cli/tests/update_e2e_test.rs
T
AskaEth ef77c67d3c fix(m171): r2 —— 跨卷守卫不许静默跳过,崩溃现场不删唯一旧副本
按首轮复审 p2-1items 的三条就地修。

P2-1(唯一阻塞项)跨卷守卫在只有一块卷的机器上静默变 ok:探不到第二块卷时
是 `eprintln! + return`,libtest 默认捕获 stdout/stderr,于是默认输出只有
`... ok`,跳过的记录只有 `--nocapture` 才看得见(复审 M3 变异实测)。
按复审给的方案 (b) 把裁决收成一条常跑用例(本仓口径同
`options_shot/feed.rs::not_fed`:跳过必须是响的):

  探到第二块卷            → 绿(真跨卷用例真跑)
  探不到 + ULL_XDEV_TEST_WAIVE=1 → 绿(显式、记录在案的降级,打印一行)
  探不到 + 没豁免         → 红(默认判红,失败正文给出三条出路)
  另有 ULL_XDEV_TEST_REQUIRE=1:声明「必须有」,与豁免同给时以它为准(不许自相矛盾)

裁决是纯函数 `xdev_guard(found, waiver, require)`:逐条用例走同一个入口
(`cross_volume_scene`),没人能靠 `return` 静静跳过;core 与 CLI e2e 两处政策一致
(源头在 `install_xdev_cases`,CLI 侧 `support::cross_volume_waived` 照抄并注明)。

CI 选择:**windows 作业显式声明豁免**(不挂 VHD)。理由:VHD 要多一步 diskpart、
要管盘符、易碎;豁免是一行 env、写在 ci.yml 里可见,且只在探测失败时被咨询——
将来 runner 真有第二块盘,真跨卷用例照样真跑。ubuntu 侧不豁免:`/tmp` 与
`/dev/shm` 是不同设备(本机 docker debian:bookworm-slim 实测设备号 119 / 158),
真跨卷守卫在 Linux 上应当是真跑的。ci.yml 不在本 mission 的改动范围,已用
TowerFinding 登记那一行补丁。

P3-1 `update_e2e_test.rs` 里 `install::prune_stale_backups` → `prune_stale_artifacts`。

P3-2 `prune_stale_artifacts(None, dest)` 会删掉崩溃现场唯一幸存的旧 exe:上一次装到
「dest → .ullold-*」之后被杀时,dest 空着、旧字节只剩备份那一份;下次成功安装时
`symlink_metadata(dest)` 失败 ⇒ keep=None ⇒ 连它一起删,用户失去手工恢复的可能
(修前不会删)。改成 keep=None 时**只清 `.ullnew-*`、一个 `.ullold-*` 都不动**,
模块文档补上这条例外与其来由。

验证(本机,CARGO_TARGET_DIR=C:/Temp/ull-target/wt-171):
* `cargo test -p unlucky-core --lib update::tests` → 90 passed / 0 failed
* `cargo test -p unlucky-core` → 623 passed(+ 各集成目标全绿),exit 0
* `cargo clippy -p unlucky-core --all-targets` / `-p unlucky-cli --all-targets
  --features update-test-source` → exit 0
* `cargo test -p unlucky-cli --features update-test-source --test update_e2e_test
  --test update_e2e_tamper_test` → 3 + 4 passed,含 `installs_across_volumes_from_the_cli`
* fmt clean
变异实测(都验完再还原):
  M3 让 probe 恒 None ⇒ 默认 `cargo test` 输出 2 failed(无须 --nocapture),
     正文「没有第二块可写卷,也没有显式豁免:跨卷守卫在这台机器上是空跑。」
  M3 + WAIVE=1 ⇒ 3 passed;M3 + REQUIRE=1 ⇒ 2 failed
  去掉 keep.is_some() 那一半 ⇒ `a_crashed_install_keeps_the_only_surviving_old_copy` 红
2026-11-08 13:34:29 +08:00

260 lines
14 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-*` | 「校验失败却已经动过目标」 | 跨卷失败路径:本文件只验了跨卷**成功**那一半(见下一行);跨卷下的中途失败由 `install_io_cases` 注入验 |
//! | 跨卷 `apply`(数据根一卷、安装目录另一卷,M171) | 「rename 不能跨卷 ⇒ 更新必然失败」这条已发布版本上的真 bug;用例自己先证明前提(rename 报 `CrossesDevices`) | 只有一块可写卷的机器上**只有显式豁免才允许跳过**(`ULL_XDEV_TEST_WAIVE=1`,探不到又没豁免 ⇒ 红);macOS 的跨卷行为一台没跑过 |
//! | 失败路径三例(`update_e2e_tamper_test`:篡改清单 sha / 篡改包体 / 截断) | 「校验判据被绕过」「失败却已动过目标」「失败留下 `.ullupd`」 | **回滚与备份**:摘要/长度失败发生在 `install_staged` **之前**,那一步根本走不到——回滚靠正路的备份断言 + `install_cases` 单测(r1 复审 ⑤ 的变异实测:去掉 dest 备份后失败路径三例仍全绿,只有正路/幂等变红) |
//!
//! # 不验的部分(如实划界)
//!
//! 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`,以及「去掉它会真的改名换掉测试二进制」这件事。
//! 5. **macOS / Linux 上的跨卷**:跨卷用例只在**本机 Windows**(C: NVMe + D: 机械盘)上真跑过;
//! macOS 的卷(APFS 宗卷之间 `/Volumes` 挂载)与 Linux 的 tmpfs↔ext4 一次都没跑——两边的
//! `EXDEV` 语义相同,但「同目录中转 + 改名」在同一台机器上有没有别的坑,本套没有证据。
#![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 从不删,等下一次启动/人工清理),但**只留最近一份**
/// (`install::prune_stale_artifacts`,M171);第二次之所以还会「装上」,是因为 `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(),
1,
"只留最近一份备份(每次 apply 建一份、成功即清更早的):{:?}",
fx.backups()
);
}
/// 跨卷(M171):数据根在一卷、安装目录在另一卷时,整环从 CLI 入口仍然跑通。
///
/// 这正是已发布版本上必然失败的那个场景(便携版放 U 盘、数据根在 D 盘而程序在 C 盘):
/// `stage()` 把包落在 `<数据根>/cache/update/staging`,`install_staged()` 要把包搬进 exe 目录,
/// 而 `rename` 不能跨卷。修法是先落到**目标同目录**的中转名(同卷改名)再顶上去。
///
/// 用例先自己证明**前提成立**(从数据根到安装目录 rename 必须报 `CrossesDevices`),否则
/// 「跨卷成功」这句话就是假的。本机只有一块可写卷时**只有显式豁免才允许跳过**
/// (`ULL_XDEV_TEST_WAIVE=1`,见 `support::cross_volume_waived`):探不到又没豁免 ⇒ 红。
/// 跨卷逻辑本身另由 `crates/core` 的 `install_xdev_cases`(真跨卷)与 `install_io_cases`(注入)守着。
#[test]
fn installs_across_volumes_from_the_cli() {
let _serial = channel_lock();
let _restore = Channel::capture();
let Some(fx) = Fixture::cross_volume("e2e-xdev") else {
support::cross_volume_waived("cli-e2e");
return;
};
Channel::enable(fx.dist.path());
fx.publish_good();
// 前提:数据根(暂存所在地)与安装目录**真的**不同卷。
let install_dir = fx.dest.parent().expect("安装目录").to_path_buf();
let probe = fx.data.path().join("volume-probe.bin");
std::fs::write(&probe, b"probe").expect("写探针");
let cross = std::fs::rename(&probe, install_dir.join("volume-probe.bin"))
.expect_err("跨卷 rename 必须失败");
assert_eq!(
cross.kind(),
std::io::ErrorKind::CrossesDevices,
"前提不成立:数据根与安装目录同卷({cross})"
);
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["path"], fx.dest.display().to_string().as_str());
assert_eq!(
fx.installed_bytes(),
fx.payload,
"装上去的必须是清单里那一份完整字节(不是半截)"
);
assert!(
fx.staging_leftovers().is_empty(),
"暂存目录里不许留 .ullupd:{:?}",
fx.staging_leftovers()
);
let backups = fx.backups();
assert_eq!(backups.len(), 1, "旧版本要留一份备份:{backups:?}");
let backup = install_dir.join(&backups[0]);
assert_eq!(
std::fs::read(&backup).expect("读备份"),
INSTALLED,
"旧字节必须完整留在备份里"
);
}