//! Saved state: old formats migrate to the current one (with a backup of the original), damaged files and entries //! never take the rest down, missing folders are reported as such, Reset keeps a backup, and the logs stay clean. use serde_json::{json, Value}; use storebound_desktop::commands::project_view; use storebound_desktop::logs; use storebound_desktop::store::{self, State, VERSION}; use storebound_desktop::workspace::{Overrides, Settings}; const D1: &str = include_str!("../../tests/fixtures/state/d1-missing-folders.json"); const D3: &str = include_str!("../../tests/fixtures/state/d3-full.json"); const EMPTY: &str = include_str!("../../tests/fixtures/state/empty.json"); fn write(dir: &std::path::Path, text: &str) { std::fs::write(dir.join(store::FILE), text).unwrap(); } #[test] fn d1_state_with_missing_folders_migrates_to_the_current_format() { let (st, from, notes) = store::migrate(serde_json::from_str(D1).unwrap()); assert_eq!((from, st.version), (1, VERSION)); assert!(notes.is_empty(), "{notes:?}"); assert_eq!(st.projects.len(), 2); assert_eq!(st.projects[0].name, "Prism Puddle"); assert!(st.accounts.is_empty() && st.studios.is_empty()); assert_eq!((st.overrides.clone(), st.settings.clone()), (Overrides::default(), Settings::default())); // the folders are gone: the window gets `missing: true` for each, nothing fails for p in &st.projects { let v = serde_json::to_value(project_view(p)).unwrap(); assert_eq!(v["missing"], true); assert_eq!(v["path"], p.path.as_str()); assert!(v.get("playAccount").is_some(), "camelCase, flattened"); } // on disk: migrated once, the original kept as state.json.v1.bak, the new file carries the version let dir = tempfile::tempdir().unwrap(); write(dir.path(), D1); let (loaded, report) = store::load_report(dir.path()); assert_eq!(loaded, st); assert_eq!(report.migrated_from, Some(1)); let bak = dir.path().join("state.json.v1.bak"); assert_eq!(report.backup.as_deref(), Some(bak.as_path())); assert_eq!(std::fs::read_to_string(&bak).unwrap(), D1); let saved: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap(); assert_eq!(saved["version"], VERSION); assert_eq!(saved["settings"]["cache"]["keepDays"], 30); let (again, report) = store::load_report(dir.path()); assert_eq!(again, st); assert_eq!(report.migrated_from, None, "migrates only once"); } #[test] fn d3_state_without_version_keeps_everything() { let (st, from, notes) = store::migrate(serde_json::from_str(D3).unwrap()); assert_eq!(from, 1); assert!(notes.is_empty(), "{notes:?}"); assert_eq!((st.projects.len(), st.accounts.len(), st.studios.len()), (2, 3, 2)); assert_eq!(st.accounts[0].studio.as_deref(), Some("s1")); assert_eq!(st.accounts[2].studio, None); assert_eq!(st.studios[1].logo, None); assert_eq!(st.overrides.ignored, vec!["account:00112233445566ff".to_string()]); assert_eq!(st.overrides.app_studio[0].studio, "s2"); } #[test] fn empty_and_missing_files_start_empty() { let (st, from, _) = store::migrate(serde_json::from_str(EMPTY).unwrap()); assert_eq!(from, 1); assert_eq!(st, State::default()); let dir = tempfile::tempdir().unwrap(); assert_eq!(store::load_report(dir.path()), (State::default(), store::LoadReport::default())); assert!(!dir.path().join(store::FILE).exists(), "nothing written for a fresh install"); } #[test] fn partial_settings_keep_what_is_there() { let (st, from, notes) = store::migrate(json!({ "version": 2, "settings": { "refresh": { "autoMinutes": 15 }, "channel": "beta" } })); assert_eq!(from, 2); assert!(notes.is_empty(), "{notes:?}"); assert_eq!((st.settings.refresh.auto_minutes, st.settings.refresh.parallel, st.settings.cache.keep_days, st.settings.channel.as_str()), (15, 4, 30, "beta")); assert!(st.settings.notify.crash); } #[test] fn bad_entries_are_dropped_one_by_one() { let (st, _, notes) = store::migrate(json!({ "projects": [ { "id": "a", "path": "/x/a", "name": "A", "added": "2026-10-01T00:00:00Z" }, { "id": 7 }, { "id": "b", "path": "/x/Beta" }, { "id": "a", "path": "/x/dup", "name": "Dup" } ], "accounts": [{ "id": "0123456789abcdef", "label": "P", "kind": "play", "created": "2026-10-01T00:00:00Z" }, { "kind": "windows-phone" }], "studios": "not a list", "overrides": { "ignored": "nope" }, "settings": null })); assert_eq!(st.projects.iter().map(|p| p.name.as_str()).collect::>(), vec!["A", "Beta"], "no name: the folder name; duplicate id: first wins"); assert_eq!(st.accounts.len(), 1); assert!(st.studios.is_empty()); assert_eq!(st.overrides, Overrides::default()); assert_eq!(st.settings, Settings::default()); assert_eq!(notes.len(), 3, "{notes:?}"); assert!(notes[0].starts_with("projects[1] dropped")); } #[test] fn unreadable_files_are_kept_aside_and_newer_formats_read() { let dir = tempfile::tempdir().unwrap(); write(dir.path(), "{broken"); let (st, report) = store::load_report(dir.path()); assert_eq!(st, State::default()); let bak = report.backup.expect("a copy of the unreadable file"); assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.unreadable-")); assert_eq!(std::fs::read_to_string(bak).unwrap(), "{broken"); write(dir.path(), "[1,2]"); assert_eq!(store::load(dir.path()), State::default()); let (st, from, _) = store::migrate(json!({ "version": 99, "projects": [{ "id": "a", "path": "/x", "name": "A", "added": "" }], "future": true })); assert_eq!((from, st.version, st.projects.len()), (99, 99, 1)); let dir = tempfile::tempdir().unwrap(); write(dir.path(), &json!({ "version": 99 }).to_string()); let (_, report) = store::load_report(dir.path()); assert!(report.notes[0].contains("newer"), "{:?}", report.notes); assert_eq!(report.migrated_from, None); } #[test] fn reset_moves_the_file_aside() { let dir = tempfile::tempdir().unwrap(); assert_eq!(store::reset(dir.path()).unwrap(), None); write(dir.path(), D1); let bak = store::reset(dir.path()).unwrap().unwrap(); assert!(bak.file_name().unwrap().to_string_lossy().starts_with("state.json.reset-")); assert_eq!(std::fs::read_to_string(bak).unwrap(), D1); assert!(!dir.path().join(store::FILE).exists()); assert_eq!(store::load(dir.path()), State::default()); } #[test] fn saved_files_always_carry_the_version() { let dir = tempfile::tempdir().unwrap(); store::save(dir.path(), &State { version: 0, ..State::default() }).unwrap(); let v: Value = serde_json::from_str(&std::fs::read_to_string(dir.path().join(store::FILE)).unwrap()).unwrap(); assert_eq!(v["version"], VERSION); } #[test] fn logs_hide_home_keys_tokens_and_emails() { let home = Some("/Users/example"); let s = logs::sanitize("TypeError at /Users/example/Library/x.js:1\n\tat f", home); assert_eq!(s, "TypeError at ~/Library/x.js:1\n\tat f"); let pem = "key: -----BEGIN PRIVATE KEY-----\nMIGTAgEAMBMGByqGSM49\n-----END PRIVATE KEY----- done"; assert_eq!(logs::sanitize(pem, None), "key: [redacted key] done"); let s = logs::sanitize(r#"{"password": "hunter22", "token":"abc.def", "name":"ok"} Authorization: Bearer xyz123 url?api_key=K1&x=1"#, None); assert!(!s.contains("hunter22") && !s.contains("abc.def") && !s.contains("xyz123") && !s.contains("K1&"), "{s}"); assert!(s.contains(r#""name":"ok""#), "{s}"); let s = logs::sanitize("tester person@example.com saw eyJhbGciOiJFUzI1NiJ9abcdef1234567890ABCDEF and 0123456789abcdef0123456789abcdef01", None); assert!(!s.contains("person@example.com") && !s.contains("eyJhbGci") && !s.contains("0123456789abcdef0123"), "{s}"); assert!(s.contains("[email]")); // ordinary paths, file names and error text stay readable let ok = "at qg (tauri://localhost/assets/index-DXUigr_F.js:49:175893) Cannot read properties of undefined (reading 'map')"; assert_eq!(logs::sanitize(ok, home), ok); assert_eq!(logs::sanitize("a\u{7}b\u{1b}[31m", None), "ab[31m"); } #[test] fn log_entries_are_one_block_and_rotate() { let e = logs::entry("2026-10-11T00:00:00Z", "webview", "error", "boom\nat f\n"); assert_eq!(e, "2026-10-11T00:00:00Z error [webview] boom\n at f\n"); let long = "x".repeat(logs::MAX_ENTRY + 10); assert!(logs::entry("t", "host", "info", &long).ends_with(" [cut]\n")); let dir = tempfile::tempdir().unwrap(); std::fs::write(dir.path().join("host.log"), vec![b'a'; logs::MAX_BYTES as usize]).unwrap(); logs::append(dir.path(), "host.log", "new\n").unwrap(); assert_eq!(std::fs::read_to_string(dir.path().join("host.log")).unwrap(), "new\n"); assert_eq!(std::fs::metadata(dir.path().join("host.1.log")).unwrap().len(), logs::MAX_BYTES); } /// What the host answers for each fixture (projects_list without `missing`, accounts_list, workspace_get), written /// next to it as `.host.json`. tools/desktop-boot-check.ts boots the built window on exactly these answers. /// After a change to the state format: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state #[test] fn host_answers_for_the_fixtures_are_current() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../tests/fixtures/state"); for (name, text) in [("empty", EMPTY), ("d1-missing-folders", D1), ("d3-full", D3)] { let (st, _, notes) = store::migrate(serde_json::from_str(text).unwrap()); assert!(notes.is_empty(), "{name}: {notes:?}"); let host = json!({ "projects_list": st.projects, "accounts_list": st.accounts, "workspace_get": { "studios": st.studios, "overrides": st.overrides, "settings": st.settings }, }); let want = format!("{}\n", serde_json::to_string_pretty(&host).unwrap()); let file = dir.join(format!("{name}.host.json")); if std::env::var_os("STOREBOUND_UPDATE_FIXTURES").is_some() { std::fs::write(&file, &want).unwrap(); } let have = std::fs::read_to_string(&file).unwrap_or_default(); assert!(have == want, "{} is stale: STOREBOUND_UPDATE_FIXTURES=1 cargo test --test state", file.display()); } }