Files
Unlucky-Launcher/crates/cli/tests/support/update_e2e.rs
T
worker-updatee2e 28bf104041 fix(m169): 复审 r1 五条 p2 —— 守门开一个极窄 allowlist 并把 E2E 真接进 CI
r1 复审(reviewer-updatee2e-r1,p2-5items,均不挡合并)逐条落地:

P2-3(本轮主产出)守门把「E2E 进 CI」这条路堵死了:`scan_enabling_text` 对 workflow 里任何该
feature 字面量判红,于是 `cargo test --release … --features update-test-source --test
update_e2e_test` 这种**合法调用**也红,自己登记的 follow-up 无法实现。现在:
- `ALLOWED_STEP = {workflow: .github/workflows/ci.yml, name: update-e2e,
  must_contain: --test update_e2e_test}`;按 `- name:` 把 workflow 切成步骤块再判,文件名 / 步骤名
  / 命令行**三者任一不符即红**(job 名不参与判据:改 job 名不该解锁新能力)。
- `ci.yml` **新增一个独立 job** `update-e2e`(windows-latest、timeout 45m、release 档;现有 job
  一行未动,纯追加 27 行)。只在 Windows:夹具的「真形状」是裸 `.exe` + `portable-exe`,Linux/macOS
  的真实产物是 `.tar.gz` + `portable-zip`(会被 `ensure_self_replaceable` 直接拒),在 ubuntu 上跑绿
  是假绿。不降 debug:`debug_assertions`/优化/panic 行为都不同。
- self-test 覆盖 allowlist 六向量(允许 / 换文件 / 换步骤名 / 缺 `--test` / 发布工作流 / ci 内另一步)。

P2-2 守门 8 条绕过:注释剥离改**引号感知**(`echo "#"; cargo …` 不再吃掉真命令);扫描面扩到
`.github/actions/**`、`packaging/**` 递归子目录、`Makefile|justfile|Taskfile` 第二构建入口;
`--all-features` 之外补 `cargo hack --feature-powerset|--each-feature|--all`。大小写/下划线变体按
复审结论写进「抓不住」表(cargo feature 名敏感,写错编不过,不算漏检)。

P2-1 GUI 人工验收命令根本跑不起来:`-p unlucky-launcher-iced` 是 bin 名,改成 package 名
`-p unlucky-app-iced`(app-iced / presenter 两处,并写明 `-p` 只认 package)。

P2-5 夹具缺「不给 --dest 就 panic」的结构性保险:`Fixture::run` 加两条断言——`apply` 必带
`--dest`、`--dest` 不许等于本进程 exe 的 canonical 路径。抓得住「以后有人手写 fx.run(&["apply"])」,
抓不住「绕开 Fixture 直接调 unlucky_cli::run」。

P2-4 夹具「真形状」只在 Windows 成立:写进 `tests/support/update_e2e.rs` 文件头(说明 Linux 上跑绿
是假绿、CI 因此只在 Windows),**不改断言**。另按复审 ⑤ 补一行:失败路径三例抓不住回滚/备份
(摘要失败发生在 install_staged 之前),回滚靠正路备份断言 + install_cases。

跨卷替换 bug 按 tower 指令**未动**(另开 mission)。

验证:守门正式扫描 + self-test 绿(14 红 / 3 绿);真仓注入 6 组(基线绿、步骤改名红、缺 --test 红、
搬进 release-build 红、改 --all-features 红、还原绿);E2E debug 6 绿、**release 6 绿**;
`cargo clippy -p unlucky-cli --all-targets --features update-test-source -- -D warnings` 干净;
`cargo fmt --all -- --check` 干净;全仓 400 行扫描无超限(守门脚本 379 行、ci.yml 317 行)。

⚠ 未验:CI 里 E2E 真的跑起来这件事本机验不了——只证明 workflow 写对 + 守门放行,首次真实运行要等
推送后看 Actions。
2026-11-08 11:12:11 +08:00

321 lines
13 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`。
//!
//! # 为什么这套「真形状」只在 Windows 上成立(r1 复审 P2-4)
//!
//! 产物名与形态按 **Windows** 的真实发布走:`unlucky-launcher_<ver>_windows_x64.exe` +
//! `portable-exe`(口径见 `packaging/make_artifacts.py` 的头注释)。Linux/macOS 的真实产物是
//! `.tar.gz` + `portable-zip`,会被 `ensure_self_replaceable` **直接拒**(不是可原地自替换的形态)。
//!
//! * 在 Windows 上跑 = 验真实形状;
//! * 在别的平台上跑绿只能证明「一个世上不存在的 linux/macOS portable-exe 能装上」——**假绿**。
//!
//! 所以 CI 里它是**一个独立的 Windows job**(`.github/workflows/ci.yml` 的 `update-e2e`,
//! `runs-on: windows-latest`),本文件也**不**为非 Windows 平台改断言:要真支持,得让产物名与
//! `format` 一起按平台分叉,那是另一个 mission 的事(真实 Linux 自更新根本不支持,D50 只发裸 exe)。
//!
//! ⚠ **验收必须给 `--dest`**:用例在**进程内**跑 `unlucky_cli::run`,`Job::for_process` 的
//! 「本进程的 exe」就是测试二进制——`apply` 不带 `--dest` 会真的把它 `rename` 成新字节
//! (Windows 允许改名正在运行的映像,见 `core::update::install` 的文档),整轮测试当场崩。
//! `Fixture::run` 里有结构性断言兜这一条(r1 复审 P2-5),不是只靠这段注释。
#![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 一样由数据根派生暂存目录)。
///
/// **结构性保险(r1 复审 P2-5)**:`apply` 必须显式带 `--dest`,且 `--dest` 不许是本进程的 exe。
/// 不带 `--dest` 就是 `Job::for_process` 的「本进程 exe」——在这套用例里那是**测试二进制**,
/// 而 Windows 允许改名正在运行的映像,于是整轮测试会把它换成一个假启动器(症状极难查)。
///
/// 抓得住:以后有人手写 `fx.run(&["apply", "--json"])`(漏 `--dest`),或把 dest 指到
/// `current_exe()`——**当场 panic**,现场不会被破坏。
/// 抓不住:绕开 `Fixture` 直接调 `unlucky_cli::run`(那等于另写一套夹具,`--data-dir` 也没人拼了)。
pub fn run(&self, args: &[&str]) -> (i32, String, String) {
if args.first() == Some(&"apply") {
assert!(
args.contains(&"--dest"),
"apply 必须带 --dest:不带会替换本进程 exe(这套用例里就是测试二进制)。\
唯一的 apply 入口是 `Fixture::apply()`"
);
}
let dest = std::fs::canonicalize(&self.dest)
.expect("dest 必须存在(Fixture::new 写入了 INSTALLED)");
if let Ok(exe) = std::env::current_exe().and_then(std::fs::canonicalize) {
assert_ne!(dest, exe, "替换目标不许是本进程的 exe");
}
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("读目标")
}
}