The launcher previously defaulted to fixtures and production generation used the source database directly. Generate a separate database and manifest set from package directories, validating staged output with the application loader before installation. Keep strict selection by default, with independent opt-ins for missing games and package version overrides in the copied database. Share database filtering and staging with the fixture publisher, and include source metadata and generation modes in the publication cache. Make normal runs consume existing production authority. Add an explicit local test recipe with separate resources, app settings, and a compiled startup game directory. Build-time gates exclude local authority from production and require Tauri development mode. Document the generation and launch workflows. Clear generated catalog copies before Tauri copies the selected resource tree so mode switches and reduced catalogs cannot retain stale manifests. Watch the copied files to repair deletion and preserve prior output mtimes only when the bytes are unchanged, allowing subsequent builds to become fresh. Test Plan: - `just fmt` -- passed. - `just clippy` -- passed with warnings denied. - `just test` -- workspace tests passed using fixture authority. - `just frontend-test` -- 94 passed. - `python3 -m unittest discover -s tools -p 'test_catalog_source_cache.py'` -- 4 passed. - `git diff --cached --check` -- passed. - Interactive GUI launches and production bundles were not exercised.
373 lines
14 KiB
Rust
373 lines
14 KiB
Rust
#[path = "../build_support/catalog_resources.rs"]
|
|
mod catalog_resources;
|
|
|
|
use std::{
|
|
fs,
|
|
path::{Path, PathBuf},
|
|
sync::atomic::{AtomicU64, Ordering},
|
|
time::{Duration, SystemTime},
|
|
};
|
|
|
|
use catalog_resources::{CatalogResources, reset_catalog_resources};
|
|
|
|
static NEXT_DIRECTORY: AtomicU64 = AtomicU64::new(0);
|
|
|
|
struct TestDirectory(PathBuf);
|
|
|
|
impl TestDirectory {
|
|
fn new() -> Self {
|
|
let path = std::env::temp_dir().join(format!(
|
|
"lanspread-catalog-resources-{}-{}",
|
|
std::process::id(),
|
|
NEXT_DIRECTORY.fetch_add(1, Ordering::Relaxed),
|
|
));
|
|
fs::create_dir(&path).expect("test directory should be unique");
|
|
Self(path)
|
|
}
|
|
}
|
|
|
|
impl Drop for TestDirectory {
|
|
fn drop(&mut self) {
|
|
fs::remove_dir_all(&self.0).expect("test directory should be removed");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn resets_catalog_copies_for_native_and_cross_target_profiles() {
|
|
let root = TestDirectory::new();
|
|
for profile in ["target/release", "custom/x86_64-pc-windows-msvc/production"] {
|
|
let profile = root.0.join(profile);
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
fs::create_dir(profile.join("manifests"))
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(profile.join("manifests/fixture.json"), b"old fixture")
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(
|
|
profile.join("manifests/removed-game.json"),
|
|
b"old production",
|
|
)
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(profile.join("game.db"), b"old database")
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(profile.join("launcher"), b"binary")
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(out_dir.join("embedded-index"), b"new authority")
|
|
.expect("fixture filesystem operation should succeed");
|
|
|
|
let resources =
|
|
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed");
|
|
assert_eq!(
|
|
resources,
|
|
[profile.join("game.db"), profile.join("manifests")]
|
|
);
|
|
assert!(resources.iter().all(|path| !path.exists()));
|
|
assert_eq!(
|
|
fs::read(profile.join("launcher"))
|
|
.expect("fixture filesystem operation should succeed"),
|
|
b"binary"
|
|
);
|
|
assert_eq!(
|
|
fs::read(out_dir.join("embedded-index"))
|
|
.expect("fixture filesystem operation should succeed"),
|
|
b"new authority"
|
|
);
|
|
// Missing copies are a normal input after manual cleanup or a clean build.
|
|
assert_eq!(
|
|
reset_catalog_resources(&out_dir).expect("fixture filesystem operation should succeed"),
|
|
resources
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_non_cargo_layout_before_removing_anything() {
|
|
let root = TestDirectory::new();
|
|
fs::write(root.0.join("game.db"), b"keep")
|
|
.expect("fixture filesystem operation should succeed");
|
|
assert!(reset_catalog_resources(&root.0.join("unexpected/launcher/out")).is_err());
|
|
assert!(reset_catalog_resources(Path::new("out")).is_err());
|
|
assert!(reset_catalog_resources(Path::new("target/release/build/launcher/out")).is_err());
|
|
assert_eq!(
|
|
fs::read(root.0.join("game.db")).expect("fixture filesystem operation should succeed"),
|
|
b"keep"
|
|
);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn rejects_linked_manifest_destination_without_traversing_it() {
|
|
let root = TestDirectory::new();
|
|
let profile = root.0.join("target/debug");
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
let source = root.0.join("source-manifests");
|
|
fs::create_dir(&source).expect("fixture filesystem operation should succeed");
|
|
fs::write(source.join("keep.json"), b"authority")
|
|
.expect("fixture filesystem operation should succeed");
|
|
std::os::unix::fs::symlink(&source, profile.join("manifests"))
|
|
.expect("fixture filesystem operation should succeed");
|
|
|
|
assert!(reset_catalog_resources(&out_dir).is_err());
|
|
assert_eq!(
|
|
fs::read(source.join("keep.json")).expect("fixture filesystem operation should succeed"),
|
|
b"authority"
|
|
);
|
|
}
|
|
|
|
fn timestamp(offset: u64) -> SystemTime {
|
|
SystemTime::UNIX_EPOCH + Duration::from_secs(1_600_000_000 + offset)
|
|
}
|
|
|
|
fn set_modified(path: &Path, modified: SystemTime) {
|
|
fs::OpenOptions::new()
|
|
.write(true)
|
|
.open(path)
|
|
.expect("fixture filesystem operation should succeed")
|
|
.set_modified(modified)
|
|
.expect("fixture filesystem operation should succeed");
|
|
}
|
|
|
|
fn write_catalog(root: &Path, database: &str, manifests: &[(&str, &str)]) {
|
|
fs::create_dir_all(root.join("manifests"))
|
|
.expect("fixture filesystem operation should succeed");
|
|
fs::write(root.join("game.db"), database).expect("fixture filesystem operation should succeed");
|
|
for (name, body) in manifests {
|
|
fs::write(root.join("manifests").join(name), body)
|
|
.expect("fixture filesystem operation should succeed");
|
|
}
|
|
for path in catalog_files(root) {
|
|
// Sources can be older than every build using them. Their timestamps
|
|
// must never replace the timestamps of changed destination bytes.
|
|
set_modified(&path, timestamp(0));
|
|
}
|
|
}
|
|
|
|
fn catalog_files(root: &Path) -> Vec<PathBuf> {
|
|
let mut paths = vec![root.join("game.db")];
|
|
paths.extend(
|
|
fs::read_dir(root.join("manifests"))
|
|
.expect("fixture filesystem operation should succeed")
|
|
.map(|entry| {
|
|
entry
|
|
.expect("manifest directory entry should be readable")
|
|
.path()
|
|
}),
|
|
);
|
|
paths.sort();
|
|
paths
|
|
}
|
|
|
|
fn simulate_tauri_copy(source: &Path, destination: &Path, modified: SystemTime) {
|
|
fs::create_dir_all(destination.join("manifests"))
|
|
.expect("fixture filesystem operation should succeed");
|
|
for path in catalog_files(source) {
|
|
let destination = destination.join(
|
|
path.strip_prefix(source)
|
|
.expect("catalog path should be below its source root"),
|
|
);
|
|
fs::copy(path, &destination).expect("fixture filesystem operation should succeed");
|
|
set_modified(&destination, modified);
|
|
}
|
|
}
|
|
|
|
fn assert_catalog_matches(source: &Path, destination: &Path, watched: &[PathBuf]) {
|
|
let expected = catalog_files(source)
|
|
.iter()
|
|
.map(|path| {
|
|
destination.join(
|
|
path.strip_prefix(source)
|
|
.expect("catalog path should be below its source root"),
|
|
)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
assert_eq!(watched, expected);
|
|
assert_eq!(catalog_files(destination), expected);
|
|
for path in watched {
|
|
let source = source.join(
|
|
path.strip_prefix(destination)
|
|
.expect("copied path should be below its destination root"),
|
|
);
|
|
assert_eq!(
|
|
fs::read(path).expect("fixture filesystem operation should succeed"),
|
|
fs::read(source).expect("fixture filesystem operation should succeed")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unchanged_copies_keep_previous_output_timestamps() {
|
|
let root = TestDirectory::new();
|
|
let source = root.0.join("source");
|
|
let profile = root.0.join("target/release");
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
write_catalog(&source, "database", &[("game.json", "manifest")]);
|
|
simulate_tauri_copy(&source, &profile, timestamp(10));
|
|
|
|
let resources =
|
|
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
|
|
simulate_tauri_copy(&source, &profile, timestamp(20));
|
|
let watched = resources.finish().expect("copied resources should finish");
|
|
|
|
assert_catalog_matches(&source, &profile, &watched);
|
|
for path in watched {
|
|
assert_eq!(
|
|
fs::metadata(path)
|
|
.expect("fixture filesystem operation should succeed")
|
|
.modified()
|
|
.expect("fixture filesystem operation should succeed"),
|
|
timestamp(10)
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn changed_copies_keep_new_timestamps_then_stabilize_on_the_next_build() {
|
|
let root = TestDirectory::new();
|
|
let source = root.0.join("source");
|
|
let profile = root.0.join("target/release");
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
write_catalog(
|
|
&source,
|
|
"old database",
|
|
&[("game.json", "old manifest"), ("shared.json", "unchanged")],
|
|
);
|
|
simulate_tauri_copy(&source, &profile, timestamp(10));
|
|
// Equal lengths ensure that changed bytes, rather than just file sizes,
|
|
// distinguish a different catalog occupying the shared profile directory.
|
|
write_catalog(
|
|
&source,
|
|
"new database",
|
|
&[("game.json", "new manifest"), ("shared.json", "unchanged")],
|
|
);
|
|
|
|
for copied_at in [timestamp(20), timestamp(30)] {
|
|
let resources =
|
|
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
|
|
simulate_tauri_copy(&source, &profile, copied_at);
|
|
let watched = resources.finish().expect("copied resources should finish");
|
|
|
|
assert_catalog_matches(&source, &profile, &watched);
|
|
for path in watched {
|
|
let expected = if path.ends_with("shared.json") {
|
|
timestamp(10)
|
|
} else {
|
|
timestamp(20)
|
|
};
|
|
assert_eq!(
|
|
fs::metadata(path)
|
|
.expect("fixture filesystem operation should succeed")
|
|
.modified()
|
|
.expect("fixture filesystem operation should succeed"),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn missing_database_manifest_or_manifest_directory_is_repaired_and_watched() {
|
|
let root = TestDirectory::new();
|
|
let source = root.0.join("source");
|
|
let profile = root.0.join("target/release");
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
write_catalog(&source, "database", &[("game.json", "manifest")]);
|
|
|
|
for missing in ["game.db", "manifests/game.json", "manifests"] {
|
|
simulate_tauri_copy(&source, &profile, timestamp(10));
|
|
let missing_path = profile.join(missing);
|
|
if missing_path.is_dir() {
|
|
fs::remove_dir_all(&missing_path).expect("fixture filesystem operation should succeed");
|
|
} else {
|
|
fs::remove_file(&missing_path).expect("fixture filesystem operation should succeed");
|
|
}
|
|
|
|
let resources =
|
|
CatalogResources::prepare(&out_dir, &source).expect("catalog resources should prepare");
|
|
simulate_tauri_copy(&source, &profile, timestamp(20));
|
|
let watched = resources.finish().expect("copied resources should finish");
|
|
|
|
assert_catalog_matches(&source, &profile, &watched);
|
|
for path in watched {
|
|
let expected = if path.starts_with(&missing_path) {
|
|
timestamp(20)
|
|
} else {
|
|
timestamp(10)
|
|
};
|
|
assert_eq!(
|
|
fs::metadata(path)
|
|
.expect("fixture filesystem operation should succeed")
|
|
.modified()
|
|
.expect("fixture filesystem operation should succeed"),
|
|
expected
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn mode_switch_and_catalog_shrink_remove_previous_manifests() {
|
|
let root = TestDirectory::new();
|
|
let fixture = root.0.join("fixture");
|
|
let production = root.0.join("production");
|
|
let profile = root.0.join("target/release");
|
|
let out_dir = profile.join("build/launcher-test/out");
|
|
fs::create_dir_all(&out_dir).expect("fixture filesystem operation should succeed");
|
|
write_catalog(
|
|
&fixture,
|
|
"fixture database",
|
|
&[
|
|
("fixture.json", "fixture only"),
|
|
("shared.json", "fixture game"),
|
|
],
|
|
);
|
|
write_catalog(
|
|
&production,
|
|
"production database",
|
|
&[
|
|
("production.json", "production only"),
|
|
("shared.json", "real game"),
|
|
],
|
|
);
|
|
simulate_tauri_copy(&fixture, &profile, timestamp(10));
|
|
|
|
let resources = CatalogResources::prepare(&out_dir, &production)
|
|
.expect("production resources should prepare");
|
|
simulate_tauri_copy(&production, &profile, timestamp(20));
|
|
assert_catalog_matches(
|
|
&production,
|
|
&profile,
|
|
&resources.finish().expect("copied resources should finish"),
|
|
);
|
|
assert!(!profile.join("manifests/fixture.json").exists());
|
|
|
|
fs::remove_file(production.join("manifests/production.json"))
|
|
.expect("fixture filesystem operation should succeed");
|
|
write_catalog(
|
|
&production,
|
|
"smaller database",
|
|
&[("shared.json", "real game")],
|
|
);
|
|
let resources = CatalogResources::prepare(&out_dir, &production)
|
|
.expect("production resources should prepare");
|
|
simulate_tauri_copy(&production, &profile, timestamp(30));
|
|
assert_catalog_matches(
|
|
&production,
|
|
&profile,
|
|
&resources.finish().expect("copied resources should finish"),
|
|
);
|
|
assert!(!profile.join("manifests/production.json").exists());
|
|
|
|
let resources =
|
|
CatalogResources::prepare(&out_dir, &fixture).expect("fixture resources should prepare");
|
|
simulate_tauri_copy(&fixture, &profile, timestamp(40));
|
|
assert_catalog_matches(
|
|
&fixture,
|
|
&profile,
|
|
&resources.finish().expect("copied resources should finish"),
|
|
);
|
|
}
|