feat: add official sync progress logging

This commit is contained in:
2026-07-07 22:13:07 +08:00
parent b2d6871926
commit ea0920e4e3
10 changed files with 639 additions and 21 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ Rust 侧官方日服资源链路已经从实验验证推进到正式入口:
4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json` 4. 能生成官方全量 pull plan,执行真实下载,维护 `official-download-manifest.json`
5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。 5. 下载后使用本地 manifest 的 size + BLAKE3 校验复用文件;官方 seed `.hash` 使用 `xxHash32(seed=0)` 强校验。
6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。 6. 支持 `.part` 断点续传、失败后 clean retry、本地 manifest audit/repair。
7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试。 7. `bat-official-sync --watch` 可常驻运行,正常检查默认每 1 小时一次;远端和本地一致时默认静默,失败后默认 60 秒快速重试CLI 默认向 stderr 输出人类可读 progress log
8. 非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一状态目录。 8. 非 dry-run 使用 `--output/.official-sync.lock` 防止并发写同一状态目录。
仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。 仍需明确:这不是完整产品完成。Go CLI 最小入口、完整 AssetBundle 解析、Patch、翻译系统、API Server 和 Web 仍是后续工作;真实官方网络全量下载 smoke test 尚未记录在仓库文档中。
+2
View File
@@ -82,6 +82,8 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
--error-retry 60s --error-retry 60s
``` ```
CLI 默认会把人类可读的阶段进度日志写到 stderr,例如自动发现、拉取 catalog、audit、下载第 N/总数个 URL 等;成功后的稳定 JSON report 仍写到 stdout。需要给上层程序保留纯机器输出时可加 `--no-progress`
生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。 生产输出目录必须使用独立状态目录,不要指向已有客户端目录,也不要指向 `/home/wanye/D/BlueArchive` 这类人工维护或开发资源目录。
--- ---
@@ -228,7 +228,7 @@ Linux 生产路径:
- pull plan 会同时包含 discovery URLs 和 content URLs - pull plan 会同时包含 discovery URLs 和 content URLs
- 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL - 全量样本下是 `2` 个 discovery URL + `5` 个内容 URL = `7` 个 URL
- `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策 - `OfficialUpdateService` 能持久化 v2 snapshot,并在远端 marker 内容变化时触发下载决策
- `bat-official-sync` 输出稳定 JSON report,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录 - `bat-official-sync` 默认向 stderr 输出人类可读 progress log,成功 JSON report 保持 stdout;支持 `--no-progress` 关闭进度日志,支持 `--watch --interval 1h --error-retry 60s` 常驻运行,非 dry-run 使用 `.official-sync.lock` 防止并发写状态目录
- `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为 - `OfficialUpdateService` 能读写 `official-bootstrap-cache.json`,并支持默认开启的 `audit_local` / `repair` CLI 行为
- 下载层能在本地文件 size/BLAKE3/path 或 manifest 不匹配时重新下载 - 下载层能在本地文件 size/BLAKE3/path 或 manifest 不匹配时重新下载
- 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测 - 官方 seed `.hash` mismatch 会导致下载失败,而不是降级为本地 BLAKE3 猜测
+1 -1
View File
@@ -157,7 +157,7 @@ target/release/bat-official-sync
--watch --watch
``` ```
`--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s``--error-retry-seconds 60` 调整。 `--watch` 是 Rust 内部持久检查模式,正常情况下默认每 1 小时执行一次检查。远端和本地一致时默认静默;有远端变化或本地文件损坏时自动下载或 repair,并输出 JSON report。下载、发现或校验失败时默认 60 秒后重试,可显式加 `--error-retry 60s``--error-retry-seconds 60` 调整。默认进度日志写到 stderr,成功 JSON report 写到 stdout;如果由上层服务严格解析 stderr/stdout,可加 `--no-progress`
### systemd service 示例 ### systemd service 示例
+1 -1
View File
@@ -229,7 +229,7 @@ cargo run -p bat-infrastructure --bin bat-official-sync -- \
--error-retry 60s --error-retry 60s
``` ```
`--interval` 是正常检查周期,默认 `1h``--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`命令成功时 stdout 输出稳定 JSON report错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期。普通错误 exit `1`,状态目录锁冲突 exit `75` `--interval` 是正常检查周期,默认 `1h``--error-retry` 是下载、发现或校验失败后的重试周期,默认 `60s`,也可以用 `--error-retry-seconds 60`CLI 默认把人类可读的阶段进度日志写到 stderr,包括自动发现、server-info、marker、catalog、audit、download 和 snapshot 阶段;成功时 stdout 输出稳定 JSON report。需要纯机器输出时加 `--no-progress`,需要显式开启则用 `--progress`错误时 stderr 输出 JSON errorwatch 模式下错误 JSON 的 `next_retry_seconds` 使用失败重试周期;如果未关闭 progress,错误 JSON 前可能已有进度日志。普通错误 exit `1`,状态目录锁冲突 exit `75`
生产可以直接运行 `--watch`,也可以用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一状态目录。 生产可以直接运行 `--watch`,也可以用 systemd service、容器或 Go 进程守护它。cron/systemd timer 仍可调用单次模式,但不再是 Rust 自动更新的唯一方式。项目是否热更新、热重载或重启进程,由上层业务集成决定。生产目录应使用独立输出目录,不要指向现有客户端或人工维护的资源目录。非 dry-run 每轮会创建 `--output/.official-sync.lock`,防止并发写同一状态目录。
+93 -5
View File
@@ -1,12 +1,13 @@
use bat_adapters::official::yostar_jp::PatchPlatform; use bat_adapters::official::yostar_jp::PatchPlatform;
use bat_infrastructure::{ use bat_infrastructure::{
OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateService, OfficialUpdateStatus, OfficialServerInfoSource, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateService,
OfficialUpdateStatus,
}; };
use serde::Serialize; use serde::Serialize;
use std::env; use std::env;
use std::path::PathBuf; use std::path::PathBuf;
use std::thread; use std::thread;
use std::time::Duration; use std::time::{Duration, Instant};
const EXIT_ERROR: i32 = 1; const EXIT_ERROR: i32 = 1;
const EXIT_LOCKED: i32 = 75; const EXIT_LOCKED: i32 = 75;
@@ -43,7 +44,10 @@ fn run() -> anyhow::Result<()> {
if options.watch { if options.watch {
run_watch(options)?; run_watch(options)?;
} else { } else {
let report = OfficialUpdateService::new().run(&options.config)?; let mut logger = ProgressLogger::new(options.progress);
let report = OfficialUpdateService::new().run_with_progress(&options.config, |event| {
logger.log(event);
})?;
if should_print_status(report.update_status, options.quiet_up_to_date) { if should_print_status(report.update_status, options.quiet_up_to_date) {
println!("{}", serde_json::to_string_pretty(&report)?); println!("{}", serde_json::to_string_pretty(&report)?);
} }
@@ -68,6 +72,7 @@ struct CliOptions {
error_retry_interval: Duration, error_retry_interval: Duration,
quiet_up_to_date: bool, quiet_up_to_date: bool,
quiet_up_to_date_explicit: bool, quiet_up_to_date_explicit: bool,
progress: bool,
} }
impl Default for CliOptions { impl Default for CliOptions {
@@ -79,6 +84,7 @@ impl Default for CliOptions {
error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS), error_retry_interval: Duration::from_secs(DEFAULT_ERROR_RETRY_SECONDS),
quiet_up_to_date: false, quiet_up_to_date: false,
quiet_up_to_date_explicit: false, quiet_up_to_date_explicit: false,
progress: true,
} }
} }
} }
@@ -96,16 +102,34 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
)); ));
} }
let service = OfficialUpdateService::new(); let service = OfficialUpdateService::new();
let mut logger = ProgressLogger::new(options.progress);
loop { loop {
let mut sleep_for = options.interval; let mut sleep_for = options.interval;
match service.run(&options.config) { logger.log_text("watch", "starting watch iteration");
match service.run_with_progress(&options.config, |event| logger.log(event)) {
Ok(report) => { Ok(report) => {
if should_print_status(report.update_status, options.quiet_up_to_date) { if should_print_status(report.update_status, options.quiet_up_to_date) {
println!("{}", serde_json::to_string_pretty(&report)?); println!("{}", serde_json::to_string_pretty(&report)?);
} }
logger.log_text(
"watch",
format!(
"iteration finished with status={}; next check in {}",
report.update_status.as_str(),
format_duration(sleep_for)
),
);
} }
Err(error) => { Err(error) => {
sleep_for = options.error_retry_interval; sleep_for = options.error_retry_interval;
logger.log_text(
"watch",
format!(
"iteration failed; retrying in {}: {}",
format_duration(sleep_for),
error
),
);
let payload = ErrorReport { let payload = ErrorReport {
status: "error", status: "error",
exit_code: EXIT_ERROR, exit_code: EXIT_ERROR,
@@ -120,6 +144,37 @@ fn run_watch(options: CliOptions) -> anyhow::Result<()> {
} }
} }
#[derive(Debug, Clone)]
struct ProgressLogger {
enabled: bool,
started_at: Instant,
}
impl ProgressLogger {
fn new(enabled: bool) -> Self {
Self {
enabled,
started_at: Instant::now(),
}
}
fn log(&mut self, event: OfficialUpdateProgress) {
self.log_text(event.stage, event.message);
}
fn log_text(&mut self, stage: &str, message: impl AsRef<str>) {
if !self.enabled {
return;
}
eprintln!(
"[+{} INFO] [{}] {}",
format_duration(self.started_at.elapsed()),
stage,
message.as_ref()
);
}
}
fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool { fn should_print_status(status: OfficialUpdateStatus, quiet_up_to_date: bool) -> bool {
!(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate) !(quiet_up_to_date && status == OfficialUpdateStatus::UpToDate)
} }
@@ -233,6 +288,12 @@ fn parse_args_from(raw_args: impl IntoIterator<Item = String>) -> anyhow::Result
options.quiet_up_to_date = false; options.quiet_up_to_date = false;
options.quiet_up_to_date_explicit = true; options.quiet_up_to_date_explicit = true;
} }
"--progress" => {
options.progress = true;
}
"--no-progress" => {
options.progress = false;
}
"--help" | "-h" => { "--help" | "-h" => {
print_usage(&binary); print_usage(&binary);
std::process::exit(0); std::process::exit(0);
@@ -273,7 +334,7 @@ fn print_usage(binary: &str) {
[--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \ [--app-version 1.70.0] [--connection-group <name>] [--launcher-version 1.7.2] \
[--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \ [--platforms Windows,Android] [--output official_update_output] [--snapshot official-sync-snapshot.json] \
[--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \ [--curl curl] [--unzip unzip] [--dry-run] [--plan] [--force] [--audit-local|--no-audit-local] [--repair|--no-repair] \
[--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date]" [--watch] [--interval 1h|60m|3600s] [--error-retry 60s] [--quiet-up-to-date|--no-quiet-up-to-date] [--progress|--no-progress]"
); );
} }
@@ -304,6 +365,14 @@ fn parse_duration(value: &str) -> anyhow::Result<Duration> {
Ok(Duration::from_secs(amount.saturating_mul(multiplier))) Ok(Duration::from_secs(amount.saturating_mul(multiplier)))
} }
fn format_duration(duration: Duration) -> String {
let total_millis = duration.as_millis();
let minutes = total_millis / 60_000;
let seconds = (total_millis / 1_000) % 60;
let millis = total_millis % 1_000;
format!("{minutes:02}:{seconds:02}.{millis:03}")
}
fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> { fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
value value
.split(',') .split(',')
@@ -368,6 +437,7 @@ mod tests {
); );
assert!(!options.quiet_up_to_date); assert!(!options.quiet_up_to_date);
assert!(!options.quiet_up_to_date_explicit); assert!(!options.quiet_up_to_date_explicit);
assert!(options.progress);
} }
#[test] #[test]
@@ -446,6 +516,18 @@ mod tests {
assert_eq!(options.error_retry_interval, Duration::from_secs(30)); assert_eq!(options.error_retry_interval, Duration::from_secs(30));
} }
#[test]
fn parses_progress_flags() {
let options = parse(&["bat-official-sync"]).unwrap();
assert!(options.progress);
let options = parse(&["bat-official-sync", "--no-progress"]).unwrap();
assert!(!options.progress);
let options = parse(&["bat-official-sync", "--no-progress", "--progress"]).unwrap();
assert!(options.progress);
}
#[test] #[test]
fn explicit_no_quiet_up_to_date_overrides_watch_default() { fn explicit_no_quiet_up_to_date_overrides_watch_default() {
let options = parse(&[ let options = parse(&[
@@ -485,6 +567,12 @@ mod tests {
assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600)); assert_eq!(parse_duration("3600").unwrap(), Duration::from_secs(3600));
} }
#[test]
fn formats_progress_durations() {
assert_eq!(format_duration(Duration::from_millis(7)), "00:00.007");
assert_eq!(format_duration(Duration::from_millis(65_432)), "01:05.432");
}
#[test] #[test]
fn quiet_up_to_date_suppresses_only_clean_reports() { fn quiet_up_to_date_suppresses_only_clean_reports() {
assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true)); assert!(!should_print_status(OfficialUpdateStatus::UpToDate, true));
+5 -4
View File
@@ -24,8 +24,9 @@ pub use cas::FileSystemCasRepository;
pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService}; pub use import::{BundleSource, ImportedResource, ResourceImportReport, ResourceImportService};
pub use official_download::{ pub use official_download::{
OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport, OfficialLocalManifestAuditItem, OfficialLocalManifestAuditReport,
OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullReport, OfficialLocalManifestAuditStatus, OfficialResourcePullItem, OfficialResourcePullProgress,
OfficialResourcePullService, OfficialResourcePullProgressKind, OfficialResourcePullReport, OfficialResourcePullService,
OfficialResourcePullStatus,
}; };
pub use official_game_main_config::OfficialGameMainConfigBootstrapService; pub use official_game_main_config::OfficialGameMainConfigBootstrapService;
pub use official_launcher::{ pub use official_launcher::{
@@ -47,8 +48,8 @@ pub use official_update::{
read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta, read_snapshot, write_bootstrap_cache, write_snapshot, ExtendedSnapshotDelta,
GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache, GameMainConfigSnapshot, LauncherMetadataSnapshot, OfficialBootstrapCache,
OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource, OfficialEndpointMarkerRole, OfficialEndpointMarkerSnapshot, OfficialServerInfoSource,
OfficialUpdateConfig, OfficialUpdateReport, OfficialUpdateService, OfficialUpdateSnapshot, OfficialUpdateConfig, OfficialUpdateProgress, OfficialUpdateReport, OfficialUpdateService,
OfficialUpdateStatus, ResolvedBootstrap, OfficialUpdateSnapshot, OfficialUpdateStatus, ResolvedBootstrap,
}; };
pub use resources::{InMemoryResourceRepository, SqliteResourceRepository}; pub use resources::{InMemoryResourceRepository, SqliteResourceRepository};
+145 -1
View File
@@ -24,6 +24,88 @@ pub enum OfficialResourcePullStatus {
Downloaded, Downloaded,
} }
impl OfficialResourcePullStatus {
/// Returns a stable label for reports and progress logs.
pub fn as_str(self) -> &'static str {
match self {
Self::SkippedExisting => "skipped_existing",
Self::Resumed => "resumed",
Self::Downloaded => "downloaded",
}
}
}
/// Progress event kind emitted while executing an official pull plan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficialResourcePullProgressKind {
/// A URL is about to be checked or downloaded.
Started,
/// A URL finished as skipped, resumed, or downloaded.
Finished,
}
impl OfficialResourcePullProgressKind {
/// Returns a stable label for progress logs.
pub fn as_str(self) -> &'static str {
match self {
Self::Started => "started",
Self::Finished => "finished",
}
}
}
/// Progress for one URL in an official pull plan.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialResourcePullProgress {
/// Progress event kind.
pub kind: OfficialResourcePullProgressKind,
/// One-based URL index in the pull plan.
pub index: usize,
/// Total URL count in the pull plan.
pub total: usize,
/// Official URL currently being processed.
pub url: String,
/// Finished pull status, when `kind` is `Finished`.
pub status: Option<OfficialResourcePullStatus>,
/// Final local file size, when `kind` is `Finished`.
pub bytes: Option<u64>,
/// Bytes transferred during this run, when `kind` is `Finished`.
pub transferred_bytes: Option<u64>,
}
impl OfficialResourcePullProgress {
fn started(index: usize, total: usize, url: String) -> Self {
Self {
kind: OfficialResourcePullProgressKind::Started,
index,
total,
url,
status: None,
bytes: None,
transferred_bytes: None,
}
}
fn finished(
index: usize,
total: usize,
url: String,
status: OfficialResourcePullStatus,
bytes: u64,
transferred_bytes: u64,
) -> Self {
Self {
kind: OfficialResourcePullProgressKind::Finished,
index,
total,
url,
status: Some(status),
bytes: Some(bytes),
transferred_bytes: Some(transferred_bytes),
}
}
}
/// Official hash algorithm used by a verified resource sidecar. /// Official hash algorithm used by a verified resource sidecar.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OfficialResourceHashAlgorithm { pub enum OfficialResourceHashAlgorithm {
@@ -240,6 +322,16 @@ impl OfficialResourcePullService {
pub fn pull( pub fn pull(
&self, &self,
plan: &OfficialResourcePullPlan, plan: &OfficialResourcePullPlan,
) -> Result<OfficialResourcePullReport, String> {
self.pull_with_progress(plan, |_| {})
}
/// Executes the plan and calls `progress` for every URL before and after it
/// is checked or downloaded.
pub fn pull_with_progress(
&self,
plan: &OfficialResourcePullPlan,
mut progress: impl FnMut(OfficialResourcePullProgress),
) -> Result<OfficialResourcePullReport, String> { ) -> Result<OfficialResourcePullReport, String> {
fs::create_dir_all(&self.output_root).map_err(|error| { fs::create_dir_all(&self.output_root).map_err(|error| {
format!( format!(
@@ -251,8 +343,17 @@ impl OfficialResourcePullService {
let official_hash_pairs = official_seed_hash_pairs(plan); let official_hash_pairs = official_seed_hash_pairs(plan);
let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs); let force_refresh_urls = official_hash_refresh_urls(&official_hash_pairs);
let mut manifest = self.read_download_manifest()?; let mut manifest = self.read_download_manifest()?;
let urls = plan.all_urls()?;
let total = urls.len();
let mut items = Vec::new(); let mut items = Vec::new();
for url in plan.all_urls()? { for (offset, url) in urls.into_iter().enumerate() {
let index = offset + 1;
progress(OfficialResourcePullProgress::started(
index,
total,
url.clone(),
));
if !is_official_yostar_jp_url(&url) { if !is_official_yostar_jp_url(&url) {
return Err(format!("Refusing to download non-official URL: {url}")); return Err(format!("Refusing to download non-official URL: {url}"));
} }
@@ -282,6 +383,14 @@ impl OfficialResourcePullService {
result result
}; };
progress(OfficialResourcePullProgress::finished(
index,
total,
url.clone(),
result.status,
result.bytes,
result.transferred_bytes,
));
items.push(OfficialResourcePullItem { items.push(OfficialResourcePullItem {
url, url,
destination, destination,
@@ -1610,6 +1719,41 @@ fi
assert!(manifest.entries.contains_key(&report.items[0].url)); assert!(manifest.entries.contains_key(&report.items[0].url));
} }
#[test]
fn pull_emits_started_and_finished_progress_events() {
let out_dir = TempDir::new().unwrap();
let bin_dir = TempDir::new().unwrap();
let curl_path = bin_dir.path().join("curl");
write_fake_curl(&curl_path);
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
let plan = build_official_pull_plan_for_platforms(
discovery_plan(),
inventory(),
&[PatchPlatform::Windows],
);
let expected_urls = plan.all_urls().unwrap().len();
let mut events = Vec::new();
let report = service
.pull_with_progress(&plan, |event| events.push(event))
.unwrap();
assert_eq!(report.items.len(), expected_urls);
assert_eq!(events.len(), expected_urls * 2);
assert_eq!(events[0].kind, OfficialResourcePullProgressKind::Started);
assert_eq!(events[0].index, 1);
assert_eq!(events[0].total, expected_urls);
assert_eq!(events[1].kind, OfficialResourcePullProgressKind::Finished);
assert_eq!(
events[1].status,
Some(OfficialResourcePullStatus::Downloaded)
);
assert!(events
.iter()
.any(|event| event.url.ends_with("/TableBundles/ExcelDB.db")));
}
#[test] #[test]
fn retries_transient_download_failures() { fn retries_transient_download_failures() {
let out_dir = TempDir::new().unwrap(); let out_dir = TempDir::new().unwrap();
+355 -6
View File
@@ -8,8 +8,9 @@
use crate::{ use crate::{
build_official_pull_plan_for_platform_inventory, build_official_sync_plan, build_official_pull_plan_for_platform_inventory, build_official_sync_plan,
changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService, changed_endpoint_urls, default_official_platforms, OfficialGameMainConfigBootstrapService,
OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullService, OfficialLauncherBootstrapService, OfficialResourcePullPlan, OfficialResourcePullProgress,
YostarJpLauncherGameConfig, YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest, OfficialResourcePullProgressKind, OfficialResourcePullService, YostarJpLauncherGameConfig,
YostarJpLauncherManifestUrl, YostarJpLauncherRemoteManifest,
}; };
use bat_adapters::official::game_main_config::YostarJpGameMainConfig; use bat_adapters::official::game_main_config::YostarJpGameMainConfig;
use bat_adapters::official::inventory::{ use bat_adapters::official::inventory::{
@@ -385,6 +386,25 @@ pub struct OfficialUpdateReport {
pub snapshot_written: Option<PathBuf>, pub snapshot_written: Option<PathBuf>,
} }
/// Human-readable progress emitted while one official update run is executing.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OfficialUpdateProgress {
/// Stable progress stage label.
pub stage: &'static str,
/// Human-readable status line.
pub message: String,
}
impl OfficialUpdateProgress {
/// Creates a progress event.
pub fn new(stage: &'static str, message: impl Into<String>) -> Self {
Self {
stage,
message: message.into(),
}
}
}
/// Official update runner. /// Official update runner.
#[derive(Debug, Clone, Default)] #[derive(Debug, Clone, Default)]
pub struct OfficialUpdateService; pub struct OfficialUpdateService;
@@ -397,10 +417,40 @@ impl OfficialUpdateService {
/// Executes one official update run. /// Executes one official update run.
pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> { pub fn run(&self, config: &OfficialUpdateConfig) -> anyhow::Result<OfficialUpdateReport> {
self.run_with_progress(config, |_| {})
}
/// Executes one official update run and emits human-readable progress
/// events. Structured reports are still returned separately.
pub fn run_with_progress(
&self,
config: &OfficialUpdateConfig,
mut progress: impl FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialUpdateReport> {
progress(OfficialUpdateProgress::new(
"start",
format!(
"starting official resource sync: output={} dry_run={} auto_discover={}",
config.output_root.display(),
config.dry_run,
config.auto_discover
),
));
let _lock = if config.dry_run { let _lock = if config.dry_run {
progress(OfficialUpdateProgress::new(
"lock",
"dry-run: skipping state lock",
));
None None
} else { } else {
Some(OfficialUpdateLock::acquire(config)?) progress(OfficialUpdateProgress::new(
"lock",
format!("acquiring state lock {}", config.lock_path().display()),
));
let lock = OfficialUpdateLock::acquire(config)?;
progress(OfficialUpdateProgress::new("lock", "state lock acquired"));
Some(lock)
}; };
let default_platforms = default_official_platforms(); let default_platforms = default_official_platforms();
@@ -416,14 +466,27 @@ impl OfficialUpdateService {
let bootstrap_cache_path = config.bootstrap_cache_path(); let bootstrap_cache_path = config.bootstrap_cache_path();
let bootstrap = if config.auto_discover { let bootstrap = if config.auto_discover {
progress(OfficialUpdateProgress::new(
"bootstrap",
format!(
"auto-discover enabled; launcher_version={} cache={}",
config.launcher_version,
bootstrap_cache_path.display()
),
));
Some(resolve_bootstrap( Some(resolve_bootstrap(
&config.launcher_version, &config.launcher_version,
&config.curl_command, &config.curl_command,
&config.unzip_command, &config.unzip_command,
&bootstrap_cache_path, &bootstrap_cache_path,
!config.dry_run, !config.dry_run,
&mut progress,
)?) )?)
} else { } else {
progress(OfficialUpdateProgress::new(
"bootstrap",
"auto-discover disabled; using explicit metadata inputs",
));
None None
}; };
@@ -451,8 +514,24 @@ impl OfficialUpdateService {
"missing connection group; pass it explicitly or enable auto-discover" "missing connection group; pass it explicitly or enable auto-discover"
) )
})?; })?;
progress(OfficialUpdateProgress::new(
"metadata",
format!(
"using app_version={} connection_group={} platforms={}",
app_version,
connection_group,
platforms_label(platforms)
),
));
let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() { let server_info_bytes = if let Some(source) = config.server_info_source.as_ref() {
progress(OfficialUpdateProgress::new(
"server-info",
format!(
"loading server-info from {}",
server_info_source_label(source)
),
));
load_server_info(source, &fetcher)? load_server_info(source, &fetcher)?
} else { } else {
let bootstrap = bootstrap.as_ref().ok_or_else(|| { let bootstrap = bootstrap.as_ref().ok_or_else(|| {
@@ -467,19 +546,47 @@ impl OfficialUpdateService {
.ok_or_else(|| { .ok_or_else(|| {
anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl") anyhow::anyhow!("official GameMainConfig has no ServerInfoDataUrl")
})?; })?;
progress(OfficialUpdateProgress::new(
"server-info",
format!("fetching discovered server-info {url}"),
));
fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)? fetcher.fetch_bytes(url).map_err(anyhow::Error::msg)?
}; };
progress(OfficialUpdateProgress::new(
"server-info",
format!("parsing server-info bytes={}", server_info_bytes.len()),
));
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?; let server_info = YostarJpServerInfo::from_slice(&server_info_bytes)?;
let current_snapshot = server_info let current_snapshot = server_info
.sync_snapshot(&connection_group, &app_version, platforms) .sync_snapshot(&connection_group, &app_version, platforms)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
let endpoint_markers = collect_endpoint_markers(&fetcher, &current_snapshot)?; progress(OfficialUpdateProgress::new(
"discovery",
format!(
"resolved addressables_root={} endpoints={}",
current_snapshot.addressables_root,
current_snapshot.endpoints.len()
),
));
progress(OfficialUpdateProgress::new(
"markers",
format!(
"checking {} remote marker endpoints",
marker_endpoint_count(&current_snapshot)
),
));
let endpoint_markers =
collect_endpoint_markers(&fetcher, &current_snapshot, &mut progress)?;
let current_update_snapshot = OfficialUpdateSnapshot::new( let current_update_snapshot = OfficialUpdateSnapshot::new(
current_snapshot.clone(), current_snapshot.clone(),
endpoint_markers, endpoint_markers,
bootstrap.as_ref(), bootstrap.as_ref(),
); );
progress(OfficialUpdateProgress::new(
"snapshot",
format!("reading previous snapshot {}", snapshot_path.display()),
));
let previous_snapshot = read_snapshot(&snapshot_path)?; let previous_snapshot = read_snapshot(&snapshot_path)?;
let previous_base_snapshot = previous_snapshot let previous_base_snapshot = previous_snapshot
.as_ref() .as_ref()
@@ -491,18 +598,48 @@ impl OfficialUpdateService {
let changed_urls = changed_endpoint_urls(&sync_plan.delta); let changed_urls = changed_endpoint_urls(&sync_plan.delta);
let remote_should_download = let remote_should_download =
config.force || sync_plan.should_download() || extended_delta.has_changes(); config.force || sync_plan.should_download() || extended_delta.has_changes();
progress(OfficialUpdateProgress::new(
"decision",
format!(
"remote decision={:?} force={} remote_should_download={}",
sync_plan.decision, config.force, remote_should_download
),
));
let pull_plan = if config.audit_local || remote_should_download || config.plan { let pull_plan = if config.audit_local || remote_should_download || config.plan {
progress(OfficialUpdateProgress::new(
"plan",
"building official pull plan from seed catalogs",
));
Some(build_pull_plan( Some(build_pull_plan(
&server_info, &server_info,
&connection_group, &connection_group,
&app_version, &app_version,
platforms, platforms,
&fetcher, &fetcher,
&mut progress,
)?) )?)
} else { } else {
progress(OfficialUpdateProgress::new(
"plan",
"pull plan not needed for clean remote state and disabled plan output",
));
None None
}; };
if let Some(pull_plan) = pull_plan.as_ref() {
let url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"plan",
format!("pull plan contains {url_count} official URLs"),
));
}
let local_audit = if config.audit_local { let local_audit = if config.audit_local {
progress(OfficialUpdateProgress::new(
"audit",
format!(
"auditing local download manifest {}",
fetcher.download_manifest_path().display()
),
));
Some( Some(
fetcher fetcher
.audit_local_manifest( .audit_local_manifest(
@@ -513,6 +650,10 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?, .map_err(anyhow::Error::msg)?,
) )
} else { } else {
progress(OfficialUpdateProgress::new(
"audit",
"local manifest audit disabled",
));
None None
}; };
let local_manifest_verified_count = local_audit let local_manifest_verified_count = local_audit
@@ -529,6 +670,20 @@ impl OfficialUpdateService {
.map(|audit| !audit.is_clean()) .map(|audit| !audit.is_clean())
.unwrap_or(false); .unwrap_or(false);
let should_download = remote_should_download || repair_needed; let should_download = remote_should_download || repair_needed;
progress(OfficialUpdateProgress::new(
"audit",
format!(
"local manifest verified={} repair_needed={}",
local_manifest_verified_count, local_manifest_repair_needed_count
),
));
progress(OfficialUpdateProgress::new(
"decision",
format!(
"final should_download={} repair_needed={}",
should_download, repair_needed
),
));
let mut report = OfficialUpdateReport { let mut report = OfficialUpdateReport {
update_status: if should_download { update_status: if should_download {
OfficialUpdateStatus::WouldDownload OfficialUpdateStatus::WouldDownload
@@ -572,6 +727,10 @@ impl OfficialUpdateService {
}; };
if !should_download { if !should_download {
progress(OfficialUpdateProgress::new(
"finish",
"up to date; no download needed",
));
return Ok(report); return Ok(report);
} }
@@ -586,18 +745,61 @@ impl OfficialUpdateService {
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
report.download_url_count = Some(urls.len()); report.download_url_count = Some(urls.len());
report.download_urls = urls; report.download_urls = urls;
progress(OfficialUpdateProgress::new(
"dry-run",
format!(
"dry-run plan generated {} URLs",
report.download_url_count.unwrap_or(0)
),
));
} else {
progress(OfficialUpdateProgress::new(
"dry-run",
"dry-run detected pending download; full URL plan disabled",
));
} }
report.update_status = OfficialUpdateStatus::WouldDownload; report.update_status = OfficialUpdateStatus::WouldDownload;
progress(OfficialUpdateProgress::new(
"finish",
"dry-run finished without writing state",
));
return Ok(report); return Ok(report);
} }
let pull_plan = pull_plan let pull_plan = pull_plan
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?; .ok_or_else(|| anyhow::anyhow!("download requested but no pull plan exists"))?;
let pull_report = fetcher.pull(pull_plan).map_err(anyhow::Error::msg)?; let download_url_count = pull_plan.all_urls().map_err(anyhow::Error::msg)?.len();
progress(OfficialUpdateProgress::new(
"download",
format!("downloading or reusing {download_url_count} official URLs"),
));
let pull_report = fetcher
.pull_with_progress(pull_plan, |event| {
progress(progress_from_pull_event(event));
})
.map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"download",
format!(
"download phase finished: downloaded={} resumed={} skipped={} transferred_bytes={}",
pull_report.downloaded_count(),
pull_report.resumed_count(),
pull_report.skipped_count(),
pull_report.transferred_bytes()
),
));
progress(OfficialUpdateProgress::new(
"audit",
"running final local manifest audit",
));
let final_audit = fetcher let final_audit = fetcher
.audit_local_manifest(pull_plan) .audit_local_manifest(pull_plan)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
progress(OfficialUpdateProgress::new(
"snapshot",
format!("writing snapshot {}", snapshot_path.display()),
));
write_snapshot(&snapshot_path, &current_update_snapshot)?; write_snapshot(&snapshot_path, &current_update_snapshot)?;
report.update_status = OfficialUpdateStatus::Downloaded; report.update_status = OfficialUpdateStatus::Downloaded;
@@ -612,6 +814,14 @@ impl OfficialUpdateService {
report.local_manifest_repair_needed_count = final_audit.repair_needed_count(); report.local_manifest_repair_needed_count = final_audit.repair_needed_count();
report.snapshot_written = Some(snapshot_path); report.snapshot_written = Some(snapshot_path);
progress(OfficialUpdateProgress::new(
"finish",
format!(
"sync finished: resources={} official_hashes_verified={}",
report.resource_count.unwrap_or(0),
report.official_seed_hash_verified_count
),
));
Ok(report) Ok(report)
} }
} }
@@ -622,11 +832,25 @@ fn build_pull_plan(
app_version: &str, app_version: &str,
platforms: &[PatchPlatform], platforms: &[PatchPlatform],
fetcher: &OfficialResourcePullService, fetcher: &OfficialResourcePullService,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<OfficialResourcePullPlan> { ) -> anyhow::Result<OfficialResourcePullPlan> {
let discovery = server_info let discovery = server_info
.discovery_plan(connection_group, app_version, platforms) .discovery_plan(connection_group, app_version, platforms)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery)?; progress(OfficialUpdateProgress::new(
"plan",
format!(
"discovery plan resolved: connection_group={} app_version={} endpoints={}",
discovery.connection_group_name,
discovery.app_version,
discovery.endpoints.len()
),
));
let seed_catalogs = fetch_seed_catalogs(fetcher, &discovery, progress)?;
progress(OfficialUpdateProgress::new(
"inventory",
"parsing seed catalogs into download inventory",
));
let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?; let inventory = build_inventory_from_seed_catalogs(&seed_catalogs, platforms)?;
Ok(build_official_pull_plan_for_platform_inventory( Ok(build_official_pull_plan_for_platform_inventory(
discovery, inventory, platforms, discovery, inventory, platforms,
@@ -652,6 +876,7 @@ fn load_server_info(
fn collect_endpoint_markers( fn collect_endpoint_markers(
fetcher: &OfficialResourcePullService, fetcher: &OfficialResourcePullService,
snapshot: &YostarJpSyncSnapshot, snapshot: &YostarJpSyncSnapshot,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> { ) -> anyhow::Result<Vec<OfficialEndpointMarkerSnapshot>> {
let mut markers = Vec::new(); let mut markers = Vec::new();
@@ -659,6 +884,15 @@ fn collect_endpoint_markers(
let Some(role) = marker_role(endpoint.kind) else { let Some(role) = marker_role(endpoint.kind) else {
continue; continue;
}; };
progress(OfficialUpdateProgress::new(
"marker",
format!(
"fetching {} marker{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher let bytes = fetcher
.fetch_bytes(&endpoint.url) .fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
@@ -689,6 +923,78 @@ fn marker_role(kind: YostarJpResourceEndpointKind) -> Option<OfficialEndpointMar
} }
} }
fn marker_endpoint_count(snapshot: &YostarJpSyncSnapshot) -> usize {
snapshot
.endpoints
.iter()
.filter(|endpoint| marker_role(endpoint.kind).is_some())
.count()
}
fn server_info_source_label(source: &OfficialServerInfoSource) -> String {
match source {
OfficialServerInfoSource::LocalPath(path) => format!("local path {}", path.display()),
OfficialServerInfoSource::OfficialFile(file_name) => {
format!("official file {file_name}")
}
OfficialServerInfoSource::OfficialUrl(url) => format!("official URL {url}"),
}
}
fn platforms_label(platforms: &[PatchPlatform]) -> String {
platforms
.iter()
.map(|platform| platform.as_str())
.collect::<Vec<_>>()
.join(",")
}
fn endpoint_kind_label(kind: YostarJpResourceEndpointKind) -> &'static str {
match kind {
YostarJpResourceEndpointKind::TableCatalog => "table_catalog",
YostarJpResourceEndpointKind::TableCatalogHash => "table_catalog_hash",
YostarJpResourceEndpointKind::AddressablesCatalog => "addressables_catalog",
YostarJpResourceEndpointKind::AddressablesCatalogHash => "addressables_catalog_hash",
YostarJpResourceEndpointKind::BundlePackingInfo => "bundle_packing_info",
YostarJpResourceEndpointKind::BundlePackingInfoHash => "bundle_packing_info_hash",
YostarJpResourceEndpointKind::MediaCatalog => "media_catalog",
YostarJpResourceEndpointKind::MediaCatalogHash => "media_catalog_hash",
}
}
fn platform_suffix(platform: Option<PatchPlatform>) -> String {
platform
.map(|platform| format!(" ({})", platform.as_str()))
.unwrap_or_default()
}
fn progress_from_pull_event(event: OfficialResourcePullProgress) -> OfficialUpdateProgress {
match event.kind {
OfficialResourcePullProgressKind::Started => OfficialUpdateProgress::new(
"download",
format!("({}/{}) started {}", event.index, event.total, event.url),
),
OfficialResourcePullProgressKind::Finished => {
let status = event
.status
.map(|status| status.as_str())
.unwrap_or("unknown");
OfficialUpdateProgress::new(
"download",
format!(
"({}/{}) finished status={} bytes={} transferred_bytes={} {}",
event.index,
event.total,
status,
event.bytes.unwrap_or(0),
event.transferred_bytes.unwrap_or(0),
event.url
),
)
}
}
}
/// Computes the extended snapshot delta. /// Computes the extended snapshot delta.
pub fn diff_extended_snapshot( pub fn diff_extended_snapshot(
current: &OfficialUpdateSnapshot, current: &OfficialUpdateSnapshot,
@@ -817,21 +1123,41 @@ fn resolve_bootstrap(
unzip_command: &Path, unzip_command: &Path,
cache_path: &Path, cache_path: &Path,
write_cache: bool, write_cache: bool,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<ResolvedBootstrap> { ) -> anyhow::Result<ResolvedBootstrap> {
let launcher = OfficialLauncherBootstrapService::with_curl_command( let launcher = OfficialLauncherBootstrapService::with_curl_command(
launcher_version, launcher_version,
curl_command.to_string_lossy().to_string(), curl_command.to_string_lossy().to_string(),
); );
progress(OfficialUpdateProgress::new(
"launcher",
"fetching official launcher game config and remote manifest",
));
let (game_config, manifest_url, manifest) = launcher let (game_config, manifest_url, manifest) = launcher
.fetch_latest_remote_manifest() .fetch_latest_remote_manifest()
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
let launcher_metadata = let launcher_metadata =
launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest); launcher_metadata_from_parts(launcher_version, &game_config, &manifest_url, &manifest);
progress(OfficialUpdateProgress::new(
"launcher",
format!(
"launcher metadata resolved: latest_version={} manifest_files={}",
launcher_metadata.game_latest_version, launcher_metadata.manifest_file_count
),
));
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("checking bootstrap cache {}", cache_path.display()),
));
if let Some(cache) = read_bootstrap_cache(cache_path)? { if let Some(cache) = read_bootstrap_cache(cache_path)? {
if let Some(game_main_config) = if let Some(game_main_config) =
cached_game_main_config_for_metadata(&cache, &launcher_metadata) cached_game_main_config_for_metadata(&cache, &launcher_metadata)
{ {
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"cache hit; reusing parsed GameMainConfig",
));
return Ok(ResolvedBootstrap { return Ok(ResolvedBootstrap {
launcher_metadata, launcher_metadata,
game_main_config, game_main_config,
@@ -840,6 +1166,10 @@ fn resolve_bootstrap(
} }
} }
progress(OfficialUpdateProgress::new(
"game-main-config",
"cache miss; fetching resources.assets and parsing GameMainConfig",
));
let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands( let bootstrapper = OfficialGameMainConfigBootstrapService::with_commands(
launcher_version, launcher_version,
curl_command.to_path_buf(), curl_command.to_path_buf(),
@@ -867,6 +1197,15 @@ fn resolve_bootstrap(
game_main_config: game_main_config.clone(), game_main_config: game_main_config.clone(),
}, },
)?; )?;
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
format!("bootstrap cache written {}", cache_path.display()),
));
} else {
progress(OfficialUpdateProgress::new(
"bootstrap-cache",
"dry-run: bootstrap cache not written",
));
} }
Ok(ResolvedBootstrap { Ok(ResolvedBootstrap {
@@ -879,6 +1218,7 @@ fn resolve_bootstrap(
fn fetch_seed_catalogs( fn fetch_seed_catalogs(
fetcher: &OfficialResourcePullService, fetcher: &OfficialResourcePullService,
discovery: &YostarJpResourceDiscoveryPlan, discovery: &YostarJpResourceDiscoveryPlan,
progress: &mut dyn FnMut(OfficialUpdateProgress),
) -> anyhow::Result<SeedCatalogs> { ) -> anyhow::Result<SeedCatalogs> {
let mut catalogs = SeedCatalogs::default(); let mut catalogs = SeedCatalogs::default();
@@ -892,6 +1232,15 @@ fn fetch_seed_catalogs(
continue; continue;
} }
progress(OfficialUpdateProgress::new(
"catalog",
format!(
"fetching seed catalog {}{} {}",
endpoint_kind_label(endpoint.kind),
platform_suffix(endpoint.platform),
endpoint.url
),
));
let bytes = fetcher let bytes = fetcher
.fetch_bytes(&endpoint.url) .fetch_bytes(&endpoint.url)
.map_err(anyhow::Error::msg)?; .map_err(anyhow::Error::msg)?;
@@ -8,7 +8,8 @@ use bat_adapters::official::yostar_jp::{
}; };
use bat_infrastructure::{ use bat_infrastructure::{
build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService, build_official_pull_plan_from_platform_inventory, OfficialGameMainConfigBootstrapService,
OfficialResourcePullService, OfficialResourcePullService, OfficialUpdateConfig, OfficialUpdateProgress,
OfficialUpdateService, OfficialUpdateStatus,
}; };
use std::collections::HashMap; use std::collections::HashMap;
use std::fs; use std::fs;
@@ -151,6 +152,39 @@ fn official_pipeline_can_dry_run_and_pull_without_local_client() {
assert!(!curl_log.contains("bluearchive.cafe")); assert!(!curl_log.contains("bluearchive.cafe"));
} }
#[test]
fn official_update_service_emits_human_progress_events() {
let harness = TestHarness::new();
let config = OfficialUpdateConfig {
auto_discover: true,
launcher_version: TEST_LAUNCHER_VERSION.to_string(),
output_root: harness.temp.path().join("sync-output"),
curl_command: harness.curl_script.clone(),
unzip_command: harness.unzip_script.clone(),
dry_run: true,
plan: true,
..OfficialUpdateConfig::default()
};
let mut events = Vec::<OfficialUpdateProgress>::new();
let report = OfficialUpdateService::new()
.run_with_progress(&config, |event| events.push(event))
.unwrap();
assert_eq!(report.update_status, OfficialUpdateStatus::WouldDownload);
assert_eq!(report.download_url_count, Some(19));
assert!(events.iter().any(|event| event.stage == "start"));
assert!(events.iter().any(|event| event.stage == "launcher"));
assert!(events.iter().any(|event| event.stage == "server-info"));
assert!(events.iter().any(|event| event.stage == "marker"));
assert!(events.iter().any(|event| event.stage == "catalog"));
assert!(events.iter().any(|event| event.stage == "dry-run"));
assert!(events.iter().any(|event| event.stage == "finish"));
assert!(events.iter().any(|event| event
.message
.contains("pull plan contains 19 official URLs")));
}
struct TestHarness { struct TestHarness {
temp: TempDir, temp: TempDir,
curl_script: std::path::PathBuf, curl_script: std::path::PathBuf,