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。
197 lines
9.9 KiB
Rust
197 lines
9.9 KiB
Rust
//! 更新通道端到端验收(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` 不能跨卷,那条路没验 |
|
||
//! | 失败路径三例(`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`,以及「去掉它会真的改名换掉测试二进制」这件事。
|
||
|
||
#![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()
|
||
);
|
||
}
|