Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a5d1dec495 |
No files matched your search
@@ -10,66 +10,18 @@ name: CI
|
||||
# 见 DevDocs/00-踩坑记录-p2.md T7)。历史实现只作源码参考,**没有构建方式**(见 README:
|
||||
# 它的 manifest 整份是 workspace 继承写法,离开 workspace 后连解析都过不了)。
|
||||
|
||||
# ---- 夜间全量矩阵(M266 / T30):为什么是 schedule,而不是 push dev ----
|
||||
# 上面那套门禁的前提是「有人往 main 推东西」,而本仓的实际节奏是**日常只推 dev**、main 一个月只在
|
||||
# 发版那一刻动一次 ⇒ 全量矩阵一个月跑一次,一次爆 5 个红,全部是早已存在的平台依赖问题。
|
||||
# `schedule` 就是补这个缺口的入口:每晚在 main 的末梢上跑一轮,把「平台回归的发现延迟」从一个月
|
||||
# 压到一天。
|
||||
#
|
||||
# **不要顺手把它改成 `push: branches: [dev]`。** 本文件的矩阵 = 两个 OS 的 build release + 两个 OS 的
|
||||
# test + 两个 OS 的 clippy + fmt + runtime-smoke + update-e2e。挂到 dev 上等于每次提交烧一轮四平台
|
||||
# 矩阵,成本与排队都不可接受(dev 白天是开发时间);而 24 小时一轮就足以把平台回归压到 T+1。
|
||||
# 这是**故意的取舍**:抓得住「平台回归 T+1 就能看见」,抓不住「当天的错当天见」。dev 线的快守门
|
||||
# (fmt / clippy / guards / gpu 自测)在 dev-guards.yml,那边刻意不上全量 test / build,理由见其头部。
|
||||
#
|
||||
# schedule 的五条语义边界(省得下次重新查;每条都能在 GitHub 文档里对上原文):
|
||||
# ① 定时只在**默认分支**上生效:cron 跑的是 **main 的末梢**,dev 上尚未合入的内容**不在这一轮里**。
|
||||
# 所以「dev 已经有人天天看着了」这句话是**错的**——dev 线只有 dev-guards.yml 那几道快守门
|
||||
# (fmt / clippy / guards / gpu 自测),全量矩阵在 dev 上一次都没跑过。另外这份文件必须先合进
|
||||
# main,cron 才会第一次响:合并之前一次都不会跑。
|
||||
# ② 失败要向人喊话:`::error::` 注解由 test 作业的 shell 步骤自己产出,不读 event 上下文,这半条
|
||||
# 与 push 一样。但**收件人不是「默认分支的维护者」**——文档原话是 "Notifications for scheduled
|
||||
# workflows are sent to the user who last modified the cron syntax in the workflow file":
|
||||
# 谁最后一次改这行 `cron`,就一直收它的告警,所以这行 cron 的改动者要当成值班人。
|
||||
# ③ 逐个 job 核过 `github.event` 依赖:除 concurrency 组那一处(`github.event_name`)与
|
||||
# runtime-smoke-macos 的 `if: github.event_name == 'workflow_dispatch'`(schedule 下为假 ⇒ 跳过,
|
||||
# 它本来就只是手动探针)之外,其余 job 与矩阵里**没有任何** event 表达式,夜间不会空转。
|
||||
# ④ 定时运行必须与 push main **分列到不同的 concurrency 组**:两者的 `github.ref` 都是
|
||||
# `refs/heads/main`,同组 + `cancel-in-progress` 会让它们互相取消 —— 而**取消不是红**。
|
||||
# 一个经常被静默取消的夜间入口比没有更坏:「昨晚没报错」会变成一句假话。所以 group 里带了
|
||||
# event_name(证据强度见 `concurrency:` 上方那几条注释,别当成已在 runner 上实证过)。
|
||||
# ⑤ **它自己会悄悄停**:public 仓库的 scheduled workflow 在仓库 60 天无活动后会被**自动 disable**
|
||||
# (文档:"In a public repository, scheduled workflows are automatically disabled when no
|
||||
# repository activity has occurred in 60 days")。这是「夜里那轮某天不再响、而且没人发现」的
|
||||
# 真实失效模式,比红更隐蔽;本仓若是 public 就适用,发现入口不跑了先看那条 disable 提示。
|
||||
#
|
||||
# 时间:`17 19 * * *`。GitHub 按 UTC 解释 cron(文档:"By default, scheduled workflows run in UTC"),
|
||||
# 所以它是 **19:17 UTC = 本地(UTC+8)次日 03:17**,本文件不再出现第二种换算口径。
|
||||
# 为什么是 17 分:文档只说 "the start of every hour" 是高负载期,且 "If the load is sufficiently high
|
||||
# enough, some queued jobs may be dropped"(负载过高时排队任务会被**丢弃**,不只是延迟),并建议错开
|
||||
# "a different time of the hour"。文档没有「:30 也挤」这条依据,所以这里只避开整点。
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
# 夜间全量矩阵(M266 / T30,理由与取舍见文件头):19:17 UTC = 本地次日 03:17。
|
||||
schedule:
|
||||
- cron: '17 19 * * *'
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUST_BACKTRACE: 1
|
||||
|
||||
concurrency:
|
||||
# 加了 schedule 之后这一行必须带上 event_name:定时运行落在默认分支上,`github.ref` 同样是
|
||||
# `refs/heads/main`,与「push main」落进同一个组 ⇒ 配上 cancel-in-progress 就是两者互相掐死
|
||||
# (夜里那轮被白天的推送取消 = 那一夜没有结果,而取消不是红,正是本次要填的洞)。带事件名之后:
|
||||
# push↔push 仍按 ref 互斥(原有行为一字未变),定时自成一列,既不会被推送取消,也不去取消推送。
|
||||
# 证据强度:以上是 **GitHub 文档语义**(schedule 事件的 ref 是默认分支、取消按 group 判定),
|
||||
# **没有在真实 runner 上构造并发验证过**——本地造不出两个并发 run。别把它当实测结论引用。
|
||||
group: ci-${{ github.event_name }}-${{ github.ref }}
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -179,59 +179,12 @@ fn type_roles_pick_the_display_stack_for_big_text() {
|
||||
/// 用 `resolve_with_faces` 而不是 `resolve_with`:`system_faces()` 不可注入,拿不到「一台没有
|
||||
/// 中文字体的机器」。这里给一张只有拉丁族的脸表 ⇒ 中文环境判出 `NeedsDownload`。
|
||||
///
|
||||
/// ## 为什么这条现在要在**自己的进程**里量(M262,ubuntu CI 真红)
|
||||
///
|
||||
/// `install::status()` 读的是**进程级**的静态槽:`install.rs:146` 的
|
||||
/// `static STATE: OnceLock<Mutex<CjkInstall>>`。而同一个 test binary(`unlucky-app-iced --lib`)
|
||||
/// 里还有别的用例会**写**它,路径如下:
|
||||
///
|
||||
/// ```text
|
||||
/// app/update/page_messages.rs:206 the_language_message_switches_the_catalog_and_rebuilds_the_view
|
||||
/// → App::update(Message::Language(Fixed(ZhCn)))
|
||||
/// → app/update/page_messages.rs:146 set_language → ui/lang.rs:135 log_cjk
|
||||
/// → fonts.rs:266 cjk_for(Some("zh_cn")) → install.rs:311 after_probe
|
||||
/// → install.rs:317-318 Step::Start → install/worker.rs:74 update(Downloading{bytes})
|
||||
/// ```
|
||||
///
|
||||
/// 触发条件:那个用例用 `Options::default()`(`screenshot: None` ⇒ `construct.rs:106`
|
||||
/// `set_offline(false)`)构造 App,而机器上**没有**中文字体时 `zh_cn` 判出 `NeedsDownload`。
|
||||
/// ubuntu runner 正是这一档(Windows 开发机装了中文字体,策略停在 `System`,所以本地看不出来)。
|
||||
/// 之后那条后台线程会真去拉 16.9 MiB(`install/worker.rs:88`),槽在下载期间一直是
|
||||
/// `Downloading`。它和本用例**并行**跑(默认多线程 harness),于是本用例读到的 `after`
|
||||
/// 完全可能是**别人**在飞的那条下载。
|
||||
///
|
||||
/// 上一版直接比 `install::status()` 的前后快照 = 拿一把全进程共享的尺子量,谁并行谁赢。
|
||||
/// 修法不是放宽断言,而是**把尺子放进只有本用例的进程**:`current_exe()` + `--exact`
|
||||
/// 重新起一次本 test binary、只跑这一条(`ULL_FONT_PURITY_CHILD=1` 防递归)。子进程里槽的初值
|
||||
/// 就是 `Unknown`,没有谁跟它抢,判据因此是确定性的。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - 把 `install::after_probe(cjk_report.strategy)` 加回求值路径(r2 判为 bug 的写法)——
|
||||
/// 子进程里槽从 `Unknown` 变成 `Downloading { bytes: 17772300 }`,`!after.is_downloading()`
|
||||
/// 当场红(实测,见报告「变异证明」);
|
||||
/// - 更宽的回归:求值路径**任何**写槽的动作(连 `Unknown → NotNeeded` 这种「无害改写」也算),
|
||||
/// 由「前后必须逐值相等」那条守;
|
||||
/// - 判据本身失效(这张脸表不再判出 `NeedsDownload`)—— 单独一条断言,免得上面两条变成假绿。
|
||||
///
|
||||
/// 抓不住:
|
||||
/// - 别的用例会不会写槽(本用例主动换进程,**不再**替全进程背书);
|
||||
/// - `App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发);
|
||||
/// - 起进程本身失败(`current_exe` 拿不到、禁 fork 的环境)——那时是**红**,不是软过;
|
||||
/// - 子进程里跑了「几条」这件事只由 stdout 的 `1 passed` 兜着(改名会让它红,而不是变 0 条假绿)。
|
||||
/// 抓得住:把 `install::after_probe(cjk_report.strategy)` 加回求值路径(那正是 r2 判为 bug 的写法)
|
||||
/// ——状态槽会从 `NotNeeded`/`Unknown` 变成 `Downloading`,下面那条断言当场红。
|
||||
/// 复审 r3 实测:加回去整套 `unlucky-app-iced --lib` **687 passed / 0 failed**(当时没有这条)。
|
||||
/// 抓不住:`App` 构造之后 `log_cjk` 那一拍会不会触发(那是 `cjk_for` 的职责,且它本来就该触发)。
|
||||
#[test]
|
||||
fn resolving_fonts_never_starts_a_download() {
|
||||
if std::env::var_os(CHILD_ENV).is_some() {
|
||||
evaluation_starts_no_download();
|
||||
return;
|
||||
}
|
||||
rerun_in_own_process();
|
||||
}
|
||||
|
||||
/// 子进程标记(防递归:父进程起子进程,子进程直接跑判据)。
|
||||
const CHILD_ENV: &str = "ULL_FONT_PURITY_CHILD";
|
||||
|
||||
/// 判据本体(只在**没有别的用例抢全局槽**的进程里跑)。
|
||||
fn evaluation_starts_no_download() {
|
||||
use unlucky_i18n::font::Strategy;
|
||||
|
||||
use crate::fonts::install;
|
||||
@@ -249,48 +202,13 @@ fn evaluation_starts_no_download() {
|
||||
let resolved = resolve_with_faces(Some("zh_cn"), Some(&latin));
|
||||
let after = install::status();
|
||||
|
||||
assert_eq!(
|
||||
before,
|
||||
install::CjkInstall::Unknown,
|
||||
"隔离失效:这个进程里槽的初值已被谁改过({before:?}),下面的前后比较就没有意义"
|
||||
);
|
||||
assert_eq!(
|
||||
resolved.cjk.strategy,
|
||||
Strategy::NeedsDownload(&unlucky_i18n::font::NOTO_SANS_SC),
|
||||
"这张脸表必须判出「需要下载」,否则后面的断言证明不了什么"
|
||||
"这张脸表必须判出「需要下载」,否则下面的断言证明不了什么"
|
||||
);
|
||||
assert!(
|
||||
!after.is_downloading(),
|
||||
"求值字体**不许**发起下载(状态从 {before:?} 变成了 {after:?})"
|
||||
);
|
||||
assert_eq!(
|
||||
after, before,
|
||||
"求值字体一次都不许写状态槽(比「不再是下载中」更强:任何改写都算越界)"
|
||||
);
|
||||
}
|
||||
|
||||
/// 用 `current_exe()` 把本 test binary 再起一次,只跑 [`resolving_fonts_never_starts_a_download`]。
|
||||
fn rerun_in_own_process() {
|
||||
let exe = std::env::current_exe().expect("取本 test binary 的路径");
|
||||
let name = "fonts::tests::resolving_fonts_never_starts_a_download";
|
||||
let output = std::process::Command::new(&exe)
|
||||
.args(["--exact", "--nocapture", "--test-threads=1", name])
|
||||
.env(CHILD_ENV, "1")
|
||||
// 截图通道的钉子会直接替换 `status()` 的返回值(`install.rs:157` 先看它),
|
||||
// 留着就等于把尺子换成别人的常数。
|
||||
.env_remove(crate::fonts::install::CAPTURE_ENV)
|
||||
.output()
|
||||
.unwrap_or_else(|error| panic!("起不了隔离子进程 {}:{error}", exe.display()));
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"隔离子进程里的判据红了(退出码 {:?})\n--- stdout ---\n{stdout}\n--- stderr ---\n{}",
|
||||
output.status.code(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("1 passed"),
|
||||
"子进程里必须**真**跑了一条用例(`--exact` 名字对不上会 0 passed 退出 0,那是假绿):\n{stdout}"
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,7 @@
|
||||
//! | 顺序层级 | [`an_empty_priority_follows_the_region`] | 让 `order_layer` 恒为 `Region` |
|
||||
//! | 超长路径不写盘 | [`an_overlong_install_root_is_refused_before_writing`] | 删掉 `Prefs::validate` 的长度判据 |
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::Path;
|
||||
|
||||
use unlucky_core::setting::keys::launcher_schema;
|
||||
use unlucky_core::setting::store::SettingStore;
|
||||
@@ -65,30 +65,7 @@ fn the_three_keys_are_written_and_read_back() {
|
||||
|
||||
/// `java.installRoot` 留空 ⇒ 生效落点来自 `core.data_mode`;填了 ⇒ 来自设置键。
|
||||
///
|
||||
/// ## 为什么不再写死 `D:/JDKs`(M262)
|
||||
///
|
||||
/// `D:/JDKs` 在 Windows 上是**绝对路径**、在 Linux 上是**相对路径**(`D:` 只是个普通目录名),
|
||||
/// 而 `JavaInstallRoot::resolve`(`crates/core/src/game/java/location.rs:128`)对两者的处理是
|
||||
/// 两件事:绝对路径原样用,相对路径拼到数据根后面。于是同一条 `assert_eq!(root, "D:/JDKs")`
|
||||
/// 在两个平台上验的是**两个不同的语义**:Windows 上验「设了就不拼数据根」(对),Linux 上验
|
||||
/// 「绝对路径被当成相对路径拼了」——ubuntu CI 因此必红。落点现在由临时目录给出,两个平台上都是
|
||||
/// 绝对路径,断言回到它本来的意思。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - `effective` 忽略 `java.installRoot`(整段红:落点会变成数据根下的 `java-managed`,
|
||||
/// `root_layer` 也退回 `DataMode`。实测:把 `override_root` 恒置空 ⇒ 红在 `root_layer`);
|
||||
/// - 「设了却仍按数据根下的 `java-managed` 来」(实测:让 `JavaInstallRoot::resolve` 的 custom 分支
|
||||
/// 返回 `launcher.java_managed()` ⇒ 红在 `root` 那句,错误的落点被打出来);
|
||||
/// - 相对路径的落点规则(第二段:相对路径**必须**拼数据根)。
|
||||
///
|
||||
/// 抓不住:
|
||||
/// - **「绝对路径被当成相对路径拼」**:`Path::join` 对一个绝对路径返回的就是它自己,于是
|
||||
/// `is_absolute()` 那一支在两个平台上都**观测不到**——实测把那一支删成恒 `home().join(root)`,
|
||||
/// 本用例仍然全绿。上一版写死 `D:/JDKs` 之所以在 Windows 上绿而在 Linux 上红,靠的不是
|
||||
/// 「拼不拼」这条分支,而是 `D:/JDKs` 在两个平台上**根本不是一类路径**。要让这一支有守卫,
|
||||
/// 得换成一个「绝对路径 join 之后会变」的平台(没有),或者直接断言 `is_absolute` 的判据本身;
|
||||
/// - 路径归一化(`D:/JDKs` → `D:\JDKs` 这类)与分隔符风格;
|
||||
/// - Standard 数据模式下拿不到系统标准目录的那一臂(`root_unresolved`,本用例钉的是 portable)。
|
||||
/// 变异证明:把 `prefs_store::effective` 里 `override_root` 那段删掉(当作永远没设)⇒ 后半段红。
|
||||
#[test]
|
||||
fn an_empty_install_root_follows_the_data_mode() {
|
||||
let dir = root("prefs-root");
|
||||
@@ -110,66 +87,15 @@ fn an_empty_install_root_follows_the_data_mode() {
|
||||
effective.root
|
||||
);
|
||||
|
||||
let custom_root = custom_install_root();
|
||||
assert!(
|
||||
!custom_root.starts_with(&dir),
|
||||
"custom 落点必须不在数据根里(否则「拼了数据根」这条也能过):{} vs {}",
|
||||
custom_root.display(),
|
||||
dir.display()
|
||||
);
|
||||
|
||||
let custom = Prefs {
|
||||
install_root: custom_root.display().to_string(),
|
||||
saved_install_root: custom_root.display().to_string(),
|
||||
install_root: "D:/JDKs".to_string(),
|
||||
saved_install_root: "D:/JDKs".to_string(),
|
||||
..Prefs::default()
|
||||
};
|
||||
let effective = prefs_store::effective(&file, &custom);
|
||||
assert_eq!(effective.root_layer, RootLayer::Setting);
|
||||
assert_eq!(effective.root_mode, "custom");
|
||||
assert_eq!(
|
||||
effective.root,
|
||||
custom_root.display().to_string(),
|
||||
"设了就按它来,不再拼数据根"
|
||||
);
|
||||
assert!(
|
||||
!effective.root_unresolved,
|
||||
"custom 落点不该是「解析失败」:{effective:?}"
|
||||
);
|
||||
|
||||
// 相对路径那一档(上一版写死的 `D:/JDKs` 在 Linux 上就落进这里):按数据根解析,
|
||||
// 但来源层仍是设置键。两个平台上语义相同——这正是上一版抓不住的那一半。
|
||||
let relative = Prefs {
|
||||
install_root: "jdks-relative".to_string(),
|
||||
saved_install_root: "jdks-relative".to_string(),
|
||||
..Prefs::default()
|
||||
};
|
||||
let effective = prefs_store::effective(&file, &relative);
|
||||
assert_eq!(effective.root_layer, RootLayer::Setting);
|
||||
assert_eq!(effective.root_mode, "custom");
|
||||
assert_eq!(
|
||||
effective.root,
|
||||
dir.join("jdks-relative").display().to_string(),
|
||||
"相对路径按数据根解析(绝对路径才原样用)"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_dir_all(&custom_root);
|
||||
}
|
||||
|
||||
/// 一个**平台无关的绝对路径**(且不在数据根里):custom 落点的替身。
|
||||
///
|
||||
/// 不能写死 `D:/JDKs` / `/opt/jdks`:前者在 Linux 上是相对路径,后者在 Windows 上是相对路径
|
||||
/// (见 [`an_empty_install_root_follows_the_data_mode`] 的文档)。临时目录两边都是绝对路径。
|
||||
fn custom_install_root() -> PathBuf {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ull-java-custom-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0, |d| d.as_nanos())
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).expect("建 custom 落点");
|
||||
dir
|
||||
assert_eq!(effective.root, "D:/JDKs", "设了就按它来,不再拼数据根");
|
||||
}
|
||||
|
||||
/// `java.vendorPriority` 留空 ⇒ 地区默认序;排过 ⇒ 顺序与来源层都变。
|
||||
|
||||
@@ -60,10 +60,7 @@ pub(crate) struct PinnedKey {
|
||||
/// (`packaging/sign_manifest.py --check-pinned-keyring` 按标记解析这一段,标记变了它就找不到表)。
|
||||
pub(crate) const PRODUCTION: &[PinnedKey] = &[
|
||||
// >>> PINNED-KEYS >>>
|
||||
PinnedKey {
|
||||
key_id: "ull-release",
|
||||
public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122",
|
||||
},
|
||||
PinnedKey { key_id: "ull-release", public_key_hex: "1c3687ecd656a29fda87a7afb56e07d2b9e223b62cf3a6f1a901fb499a97b122" },
|
||||
// <<< PINNED-KEYS <<<
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
//! 这里用**同一份** `snapshot.rs` 证明:目录节点、链接节点都进快照,读盘失败不吞。
|
||||
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, SystemTime};
|
||||
|
||||
use crate::fixture::{
|
||||
link_guard, snapshot, snapshot_opt, try_link_dir, try_symlink_dir, Node, TempRoot,
|
||||
@@ -97,111 +96,22 @@ fn snapshot_catches_a_new_empty_directory() {
|
||||
|
||||
/// 目录的 mtime 变化也要能看见(条目增删会改父目录 mtime)。
|
||||
///
|
||||
/// ## 为什么不再「写个文件然后赌 mtime 变了」(M262)
|
||||
///
|
||||
/// 上一版只做「建目录 → 拍快照 → 写文件 → 再拍快照 → `assert_ne!(node)`」,靠的是**文件系统
|
||||
/// 恰好把两次操作记成不同的 mtime**。这一条在 windows CI 上真红(写文件与建目录落在同一 tick),
|
||||
/// 而它自己的注释还写着「真粗到同一 tick 时会软过」——既不可靠也不软过,是个真红。
|
||||
///
|
||||
/// 现在两个 mtime 都由本用例**显式给定**([`set_dir_mtime`]),不来自时钟:t0 = 1_000_000_000s,
|
||||
/// t1 = t0 + 3600s,两者都是整秒、都对任何常见粒度(NTFS 100ns / ext4 1ns / HFS+ 1s / FAT 2s)
|
||||
/// 无余数,于是「快照读到的目录 mtime」与「我们钉的那个值」可以逐 ns 比对。
|
||||
///
|
||||
/// 抓得住:
|
||||
/// - `Node::Dir` 不再携带 mtime(记 0 / 记常量)—— 逐 ns 断言当场红;
|
||||
/// - 快照不再重新读元数据(缓存 / 只记路径)—— 同上;
|
||||
/// - 目录节点整个丢失(`expect` 红)。
|
||||
///
|
||||
/// 抓不住:
|
||||
/// - **文件系统会不会自己在条目增删时抬高目录 mtime**:本用例把这件事绕开了(两次 mtime 都是我们
|
||||
/// 钉的),所以「NTFS 真机上目录 mtime 会不会被延时更新」这件事依然**没有**用例守——它有自己
|
||||
/// 的用例(`snapshot_catches_a_new_empty_directory` 比的是路径集合,不靠 mtime);
|
||||
/// - 粒度比 3600s 还粗的文件系统(常见 FS 里没有;真遇到会红在「逐 ns 相等」那句,不会软过);
|
||||
/// - 「目录里多了一个文件」这件事本身(那由 [`snapshot_catches_a_new_empty_directory`] 与
|
||||
/// 下面 `watched/new.txt` 那一条单独守,不靠 mtime)。
|
||||
/// 抓得住:目录节点只记路径不记 mtime(那样 `Node::Dir{mtime_ns}` 相同,第二条断言红)。
|
||||
/// 抓不住:某些文件系统对 mtime 的粗粒度(本机 NTFS 是 100ns 级;真粗到同一 tick 时这条会软过)。
|
||||
#[test]
|
||||
fn snapshot_notices_a_directory_mtime_change() {
|
||||
let root = TempRoot::new("snap-dirmtime");
|
||||
let dir = root.path().join("watched");
|
||||
std::fs::create_dir_all(&dir).expect("建目录");
|
||||
|
||||
// 两个明确的时刻(整秒,任何常见 filetime 粒度都能原样存下)。
|
||||
let t0 = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000_000);
|
||||
let t1 = t0 + Duration::from_secs(3_600);
|
||||
|
||||
set_dir_mtime(&dir, t0);
|
||||
let before = snapshot(root.path());
|
||||
|
||||
// 场景照旧是真的:往目录里加了一个文件(条目增删是 mtime 变的**真实**来源)。
|
||||
std::fs::write(dir.join("new.txt"), b"x").expect("写文件");
|
||||
// 但差异由本用例给出,而不是「期望这次写恰好落在另一个 tick」。
|
||||
set_dir_mtime(&dir, t1);
|
||||
|
||||
let after = snapshot(root.path());
|
||||
|
||||
// 场景本身要成立(否则下面比的是一个没变的世界)。
|
||||
assert!(
|
||||
!before.contains_key("watched/new.txt")
|
||||
&& matches!(after.get("watched/new.txt"), Some(Node::File { .. })),
|
||||
"先把「目录里真多了一个文件」这条落实:before={:?} after={:?}",
|
||||
before.get("watched/new.txt"),
|
||||
after.get("watched/new.txt")
|
||||
);
|
||||
|
||||
let before_node = before.get("watched").expect("有该目录节点");
|
||||
let after_node = after.get("watched").expect("有该目录节点");
|
||||
assert_ne!(
|
||||
before_node, after_node,
|
||||
"目录 mtime 变了,目录节点必须变(mtime 是我们钉的,不看时钟)"
|
||||
"目录里加了文件,目录节点(含 mtime)必须变"
|
||||
);
|
||||
assert_eq!(
|
||||
dir_mtime_ns(after_node),
|
||||
t1.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("t1 在纪元之后")
|
||||
.as_nanos(),
|
||||
"快照里的目录 mtime 必须就是我们钉的那个值(逐 ns)"
|
||||
);
|
||||
assert_eq!(
|
||||
dir_mtime_ns(before_node),
|
||||
t0.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.expect("t0 在纪元之后")
|
||||
.as_nanos()
|
||||
);
|
||||
}
|
||||
|
||||
/// 给**目录**显式钉一个 mtime(std-only;两个平台的句柄打开方式不同)。
|
||||
#[cfg(windows)]
|
||||
fn set_dir_mtime(dir: &Path, at: SystemTime) {
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
// 0x0100 = FILE_WRITE_ATTRIBUTES:`SetFileTime` 要的就是这个权限,而 GENERIC_WRITE
|
||||
// (`OpenOptions::write(true)`)在**目录**上会被拒(os error 5)。
|
||||
// 0x0200_0000 = FILE_FLAG_BACKUP_SEMANTICS:不开它 `CreateFile` 根本打不开目录句柄。
|
||||
let handle = std::fs::OpenOptions::new()
|
||||
.access_mode(0x0100)
|
||||
.custom_flags(0x0200_0000)
|
||||
.open(dir)
|
||||
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
|
||||
handle
|
||||
.set_modified(at)
|
||||
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
|
||||
}
|
||||
|
||||
/// 同 [`set_dir_mtime`](`futimens` 走普通目录句柄,不需要额外权限)。
|
||||
#[cfg(not(windows))]
|
||||
fn set_dir_mtime(dir: &Path, at: SystemTime) {
|
||||
let handle = std::fs::File::open(dir)
|
||||
.unwrap_or_else(|error| panic!("打不开目录句柄 {}:{error}", dir.display()));
|
||||
handle
|
||||
.set_modified(at)
|
||||
.unwrap_or_else(|error| panic!("给目录设 mtime 失败 {}:{error}", dir.display()));
|
||||
}
|
||||
|
||||
/// 目录节点的 mtime(纳秒)。节点不是目录就直接红:本用例只谈目录。
|
||||
fn dir_mtime_ns(node: &Node) -> u128 {
|
||||
match node {
|
||||
Node::Dir { mtime_ns } => *mtime_ns,
|
||||
other => panic!("watched 应当是目录节点:{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 目标根本不存在 ⇒ `snapshot_opt` 给 `None`(与「空树」区分开)。
|
||||
|
||||
@@ -144,11 +144,6 @@ fn a_truly_missing_versions_dir_stays_empty() {
|
||||
}
|
||||
|
||||
/// 目录里直接子项的名字(排序后,用于「有没有新增」的判据)。
|
||||
///
|
||||
/// cfg 必须与唯一的调用点(`a_junction_on_the_instances_dir_must_not_write_into_the_game_root`)
|
||||
/// 对齐:那条用例是 `#[cfg(windows)]`(目录联接夹具只在 Windows 上建得出来),Linux 上调用点
|
||||
/// 整块编掉,未 gate 的 helper 就成了 `dead_code`(CI 的 `-D warnings` 会让 clippy 退出 101)。
|
||||
#[cfg(windows)]
|
||||
fn listing(dir: &std::path::Path) -> Vec<String> {
|
||||
let mut names: Vec<String> = std::fs::read_dir(dir)
|
||||
.expect("读目录")
|
||||
|
||||
@@ -45,6 +45,6 @@ update-test-source = ["unlucky-core/update-test-source"]
|
||||
# `manifest.json.sig`,没有它 core 一律拒装)。这里打开 core 的 `update-test-key`:它**只**把一把
|
||||
# **公开**的测试公钥并进信任表,不开任何数据源开关(与 `update-test-source` 是两件事)。
|
||||
# 只出现在 `[dev-dependencies]`:库/发布构建拿不到它。守门:`packaging/check_test_key_off.py`。
|
||||
unlucky-core = { path = "../core", version = "0.2.4", features = ["update-test-key"] }
|
||||
unlucky-core = { path = "../core", features = ["update-test-key"] }
|
||||
# 夹具用同一套种子现签清单(与 `crates/core/src/update/tests/support_sign.rs` 同源)。
|
||||
ed25519-dalek.workspace = true
|
||||
@@ -7,16 +7,11 @@
|
||||
|
||||
| 通道 | 位置 | 后端 |
|
||||
|---|---|---|
|
||||
| CI 冒烟截屏 | `.github/workflows/ci.yml` 的 `test` 作业「出四张像素守门用的图并写进 $GITHUB_ENV(仅 Windows)」那一步 | `--software-renderer`(强制 CPU) |
|
||||
| CI 交互冒烟 | 同上文件的 `runtime-smoke` 作业「在 Xvfb 里跑一帧并截图」、`runtime-smoke-macos` 作业「尝试截图」两步 | `--software-renderer` |
|
||||
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh` 里「2) 跑容器:构建 → xvfb 截图 → 像素判定」那一段的 `xvfb-run` | `--software-renderer` |
|
||||
| CI 冒烟截屏 | `.github/workflows/ci.yml:101` | `--software-renderer`(强制 CPU) |
|
||||
| CI 交互冒烟 | `.github/workflows/ci.yml:246`、`:283` | `--software-renderer` |
|
||||
| Linux 容器冒烟 | `packaging/linux-smoke-docker.sh:90` | `--software-renderer` |
|
||||
| D30 视觉验收(人工,按协议出图) | `DevDocs/PlanDoc/`(视觉验收协议) | 同上(CI 同款命令) |
|
||||
|
||||
> **锚点一律用「作业名 + 步骤名」,不用 `文件:行号`。** 这条规则不是本次改动造出来的:本次改动的
|
||||
> base(`main`)上,上面 ci.yml 那三处行号里只有 `:101` 对得上,`:246` / `:283` 早就指偏了(分别落在
|
||||
> 依赖安装步和一行注释上;`linux-smoke-docker.sh:90` 那时是对的)。也就是说行号锚点本来就在腐,
|
||||
> 而 workflow 只会越来越长——要定位就按上表的名字 `grep` 作业名 / 步骤名,别再写回行号。
|
||||
|
||||
`--renderer=auto`(默认)会挑 wgpu;`--software-renderer` 强制 tiny-skia。CI 走后者是**有意的**:
|
||||
容器/无 GPU 机器上没有 Vulkan/DX12 设备,硬要 wgpu 会红在一句「adapter 创建失败」上,与皮肤无关。
|
||||
|
||||
|
||||
@@ -256,12 +256,6 @@ def do_self_test() -> int:
|
||||
if pinned_keys(sample) != [("k1", "ab" * 32)]:
|
||||
print("FAIL: pinned_keys() 没解析出示例条目", file=sys.stderr)
|
||||
failures += 1
|
||||
# 多行形态(M261):`trust.rs` 里那一条是 `cargo fmt` 生成的,字段一行一个 —— 靠 `KID_RE` 的 `\s*`
|
||||
# 跨行命中。若解析器退化成按行匹配,一次 fmt 就会把整张信任表**静默**读成空表(= 全量客户端验签失败)。
|
||||
multiline = f'{PIN_BEGIN}\n PinnedKey {{\n key_id: "k1",\n public_key_hex: "{"ab" * 32}",\n }},\n{PIN_END}'
|
||||
if pinned_keys(multiline) != [("k1", "ab" * 32)]:
|
||||
print("FAIL: pinned_keys() 没解析出多行(rustfmt)形态的条目", file=sys.stderr)
|
||||
failures += 1
|
||||
if pinned_keys(f"{PIN_BEGIN}\n{PIN_END}") != []:
|
||||
print("FAIL: pinned_keys() 在空表上应返回空", file=sys.stderr)
|
||||
failures += 1
|
||||
|
||||
Reference in new issue
Block a user