Files
vectorcraft/crates/engine/src/tests_recovery.rs
T
bflatasticandClaude Opus 5.5 649656b092 Fix #369: restart the web build's graphics after a lost device or context
When the browser dropped the page's WebGPU device or WebGL context, the
editor kept running but nothing it drew reached the screen: Chrome and
WebKit showed a frozen, stale canvas and Firefox a blank page, while
edits and Save went on.

- ui-egui: new `graphics` module. `GraphicsLoss` carries a loss report
  from any thread (wgpu's device-lost callback, the canvas's
  `webglcontextlost` event) to the UI thread. `VectorcraftApp::
  graphics_lost` writes Data Recovery copies of the modified documents at
  once. The app now notices when the host hands it a new egui context and
  uploads everything again (fonts and theme, the canvas, place cursor
  thumbnails); every per-thread texture cache (layer, symbol, swatch,
  style, asset and glyph thumbnails, previews, font samples, Navigator,
  Flattener Preview) is a `TexCache` that empties for a new context.
  Without this, the moved app drew stale or wrong textures and egui
  panicked on its missing font families.
- web: the shell watches both loss signals; on a loss it puts a fresh
  canvas in place of the dead one and starts the same `WebRunner` again,
  handing over the same app (documents, undo, panels), with services made
  for the new context and a status-bar note. If no new graphics can be had
  (or after three losses) it removes the stale canvas, lets go of the
  recovery copies and says to reload.
- engine: `recovery::leave` lets go of a session's area but keeps its
  copies, so the reloaded page offers them at once instead of after the
  heartbeat goes stale.
- desktop: eframe can't give a window a new device, so a lost device
  (driver reset) is logged, the copies are written and the status bar says
  to save and restart; closing then skips the Save Changes question it
  can't show.
- Tests: loss reporting across threads, `TexCache` emptying, the app
  uploading the canvas, a cached preview, theme and fonts again in a new
  context (fails without the fix), copies written on loss, and
  `recovery::leave` offering copies at once with both lock and heartbeat
  stores. docs/development.md (web section) and ROADMAP updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-08 07:18:32 +07:00

445 lines
22 KiB
Rust

//! Data Recovery: `file.recovery.save/list/restore/discard`, copies removed on save, revert and
//! close, complex documents skipped, each app's own area (another running app's copies are never
//! offered: locks, heartbeats, announcements; a paused app repairs its copies), and the folder
//! store with real file locks.
use std::sync::Arc;
use serde_json::json;
use super::*;
use crate::cmd::recovery::{FolderStore, MemoryStore, RecoveryStore};
/// A session keeping its copies in `store`, with one modified document.
fn session_in(store: &Arc<MemoryStore>) -> Session {
let mut s = Session::new();
s.recovery.set_store(store.clone());
s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap();
s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 50, "height": 20})).unwrap();
s
}
/// A session keeping its copies in memory (the store is returned to look inside), with one
/// modified document.
fn session() -> (Session, Arc<MemoryStore>) {
let store = Arc::new(MemoryStore::default());
(session_in(&store), store)
}
/// The copies in the store, without their areas.
fn copies(store: &dyn RecoveryStore) -> Vec<String> {
let mut v: Vec<String> =
store.list().unwrap().iter().filter_map(|n| n.strip_suffix(".vectorcraft")).map(|n| n.split_once('/').unwrap().1.to_string()).collect();
v.sort();
v
}
/// The titles of the copies `file.recovery.list` offers after a crash (neither open here nor
/// kept by a running app).
fn offered(s: &mut Session) -> Vec<String> {
let l = s.execute("file.recovery.list", &json!({})).unwrap();
l["copies"]
.as_array()
.unwrap()
.iter()
.filter(|c| c["open"] == false && c["running"] == false)
.map(|c| c["title"].as_str().unwrap().to_string())
.collect()
}
/// A crash: a new session over the same store (the copies stay behind; dropping the old session
/// releases its lock, as the system does for a process that ends).
fn relaunch(store: &Arc<MemoryStore>) -> Session {
let mut s = Session::new();
s.recovery.set_store(store.clone());
s
}
fn restored(r: serde_json::Value) -> usize {
r["restored"].as_array().unwrap().len()
}
#[test]
fn recovery_save_writes_modified_documents_once() {
let (mut s, store) = session();
// A clean second document gets no copy.
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
let r = s.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(r["saved"].as_array().unwrap().len(), 1, "{r}");
let area = s.recovery.own_area().unwrap().to_string();
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
assert_eq!(store.list().unwrap(), [format!("{area}/Untitled-1-1.json"), format!("{area}/Untitled-1-1.vectorcraft")]);
let doc = store.read(&format!("{area}/Untitled-1-1.vectorcraft")).unwrap();
assert_eq!(vectorcraft_format::load(&doc).unwrap().node_count(), s.documents()[0].doc.node_count());
// Unchanged since: nothing written again.
let again = s.execute("file.recovery.save", &json!({})).unwrap();
assert!(again["saved"].as_array().unwrap().is_empty());
assert_eq!(again["skipped"][0]["reason"], "unchanged since its last recovery copy");
// Changed: the same copy is rewritten.
s.execute("document.activate", &json!({"index": 0})).unwrap();
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
let r = s.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(r["saved"][0]["file"], format!("{area}/Untitled-1-1"));
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
// The list shows it as the copy of an open document, not one to offer.
let l = s.execute("file.recovery.list", &json!({})).unwrap();
assert_eq!((l["copies"][0]["title"].as_str(), l["copies"][0]["open"].as_bool()), (Some("Untitled-1"), Some(true)));
assert_eq!(l["location"], "memory");
assert!(offered(&mut s).is_empty());
}
#[test]
fn a_crash_leaves_copies_that_restore_modified_and_titled() {
let (mut s, store) = session();
s.doc_mut().unwrap().path = Some("/art/Poster.svg".into());
s.doc_mut().unwrap().format = "svg";
s.execute("file.recovery.save", &json!({})).unwrap();
let nodes = s.doc().unwrap().doc.node_count();
let crashed = s.recovery.own_area().unwrap().to_string();
drop(s);
let mut s = relaunch(&store);
let l = s.execute("file.recovery.list", &json!({})).unwrap();
let copy = &l["copies"][0];
assert_eq!(copy["file"], format!("{crashed}/Poster-1"));
assert_eq!((copy["path"].as_str(), copy["open"].as_bool(), copy["running"].as_bool()), (Some("/art/Poster.svg"), Some(false), Some(false)));
let r = s.execute("file.recovery.restore", &json!({})).unwrap();
assert_eq!(r["restored"][0]["title"], "Poster [Recovered]");
let st = s.doc().unwrap();
assert_eq!(st.title(), "Poster [Recovered]");
assert!(st.is_dirty() && st.recovered, "restored documents are unsaved");
assert_eq!((st.path.as_deref(), st.format, st.doc.node_count()), (Some("/art/Poster.svg"), "svg", nodes));
// The copy moved into this session's area; the crashed one's area is gone.
let mine = s.recovery.own_area().unwrap().to_string();
assert_eq!(st.recovery.as_ref().unwrap().file, format!("{mine}/Poster-1"));
assert_eq!(store.areas().unwrap(), std::slice::from_ref(&mine));
// Save asks for a path (suggesting the original file) rather than overwriting it.
let saved = s.execute("document.save", &json!({})).unwrap();
assert!(saved.get("dataBase64").is_some(), "no path: {saved}");
assert_eq!(saved["name"], "Poster.svg");
// Restored once: nothing is left to restore.
assert_eq!(restored(s.execute("file.recovery.restore", &json!({})).unwrap()), 0);
assert!(s.execute("file.recovery.restore", &json!({"file": format!("{mine}/Poster-1")})).is_err(), "an open document's copy");
}
#[test]
fn another_running_apps_copies_are_never_offered() {
let store = Arc::new(MemoryStore::default());
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap());
// B runs at the same time: A's copy is listed as running, never offered or taken.
let mut b = session_in(&store);
b.execute("file.recovery.save", &json!({})).unwrap();
let l = b.execute("file.recovery.list", &json!({})).unwrap();
let rows = l["copies"].as_array().unwrap();
assert_eq!(rows.len(), 2);
assert!(rows.iter().any(|c| c["file"] == a_file.as_str() && c["running"] == true));
assert!(offered(&mut b).is_empty());
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
let e = b.execute("file.recovery.discard", &json!({"file": a_file})).unwrap_err().to_string();
assert!(e.contains("running"), "{e}");
assert_eq!(copies(store.as_ref()).len(), 2, "nothing deleted");
// A quits normally: its area goes, and B still has nothing to offer.
a.execute("file.close", &json!({})).unwrap();
cmd::recovery::forget_all(&mut a);
assert!(offered(&mut b).is_empty());
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"], "B's own copy");
}
#[test]
fn a_crashed_apps_copies_are_offered_once_its_lock_is_released() {
let store = Arc::new(MemoryStore::default());
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let mut b = relaunch(&store);
assert!(offered(&mut b).is_empty(), "A still runs");
drop(a);
assert_eq!(offered(&mut b), ["Untitled-1"]);
// Restored by B, the copy is B's: C, launched at the same time, can't take it.
let mut c = relaunch(&store);
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
assert!(offered(&mut c).is_empty(), "B owns the copy now");
assert_eq!(restored(c.execute("file.recovery.restore", &json!({})).unwrap()), 0);
}
#[test]
fn heartbeats_hold_areas_where_the_store_has_no_locks() {
let store = Arc::new(MemoryStore::without_locks());
store.set_now(10_000);
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let mut b = relaunch(&store);
b.prefs.autosave_interval = 2;
// Three intervals (6 minutes) without a heartbeat: until then A counts as running.
store.set_now(10_000 + 359);
assert!(offered(&mut b).is_empty());
// A beats (as the app does every minute): fresh again.
cmd::recovery::heartbeat(&mut a);
store.set_now(10_000 + 359 + 300);
assert!(offered(&mut b).is_empty());
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
// A stops beating (it crashed): its copies are offered once three intervals have passed.
drop(a);
store.set_now(10_000 + 359 + 361);
assert_eq!(offered(&mut b), ["Untitled-1"]);
let mut c = relaunch(&store);
// B restores the copies one by one (as the dialog does): its own heartbeat on A's area doesn't
// stop it, and C sees the area taken.
let file = b.execute("file.recovery.list", &json!({})).unwrap()["copies"][0]["file"].as_str().unwrap().to_string();
b.execute("file.recovery.restore", &json!({"file": file})).unwrap();
assert!(offered(&mut c).is_empty());
assert_eq!(b.doc().unwrap().title(), "Untitled-1 [Recovered]");
// Never under three minutes, whatever the interval.
let store = Arc::new(MemoryStore::without_locks());
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
drop(a);
let mut b = relaunch(&store);
b.prefs.autosave_interval = 1;
store.set_now(1_000_000 + 179);
assert!(offered(&mut b).is_empty());
store.set_now(1_000_000 + 181);
assert_eq!(offered(&mut b), ["Untitled-1"]);
}
/// Issue #367: a tab whose timers were paused (a background tab) long enough for its heartbeat to
/// go stale, while another tab discards the copies it takes for crash leftovers.
#[test]
fn a_paused_app_writes_its_copies_again_and_is_running_again() {
let store = Arc::new(MemoryStore::without_locks());
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap());
let mut b = relaunch(&store);
b.prefs.autosave_interval = 1;
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
// A is paused past three minutes: with heartbeats alone, B can't tell it from a crash.
store.set_now(1_000_000 + 186);
assert_eq!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"], json!([a_file.clone()]));
assert!(copies(store.as_ref()).is_empty());
// A resumes: its heartbeat finds the copy gone and the next save writes it again, unchanged
// document or not.
store.set_now(1_000_000 + 200);
assert_eq!(cmd::recovery::heartbeat(&mut a), [a.doc().unwrap().uid]);
assert!(a.doc().unwrap().recovery.is_none());
let r = a.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(r["saved"][0]["file"], a_file.as_str(), "{r}");
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
assert!(cmd::recovery::heartbeat(&mut a).is_empty(), "nothing missing any more");
// A's heartbeat is newer than the one B wrote taking the area over: A runs again for B.
let l = b.execute("file.recovery.list", &json!({})).unwrap();
assert_eq!((l["copies"][0]["file"].as_str(), l["copies"][0]["running"].as_bool()), (Some(a_file.as_str()), Some(true)));
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
assert!(b.execute("file.recovery.discard", &json!({"file": a_file})).is_err());
// A recovery save alone repairs too (no heartbeat in between).
store.set_now(1_000_000 + 600);
let mut c = relaunch(&store);
c.prefs.autosave_interval = 1;
c.execute("file.recovery.discard", &json!({})).unwrap();
assert!(copies(store.as_ref()).is_empty());
assert_eq!(a.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().len(), 1);
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
}
/// Issue #367 where the store can tell a paused app from a gone one (the browser's Web Locks): the
/// paused app's copies are never taken, however old its heartbeat.
#[test]
fn announced_areas_stay_held_while_their_app_is_paused() {
let store = Arc::new(MemoryStore::announcing());
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let a_file = format!("{}/Untitled-1-1", a.recovery.own_area().unwrap());
store.set_now(1_000_000 + 3600);
let mut b = relaunch(&store);
assert!(offered(&mut b).is_empty());
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 0);
assert!(b.execute("file.recovery.discard", &json!({"file": a_file})).unwrap_err().to_string().contains("running"));
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
// A crashes: the browser releases its lock, and its copies are offered once its heartbeat is
// stale too.
drop(a);
assert_eq!(offered(&mut b), ["Untitled-1"]);
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
// B's area is announced in turn.
let mut c = relaunch(&store);
store.set_now(1_000_000 + 7200);
assert!(offered(&mut c).is_empty());
}
#[test]
fn an_app_that_leaves_has_its_copies_offered_at_once() {
for store in [MemoryStore::without_locks(), MemoryStore::announcing(), MemoryStore::default()] {
let store = Arc::new(store);
let mut a = session_in(&store);
a.execute("file.recovery.save", &json!({})).unwrap();
let mut b = relaunch(&store);
assert!(offered(&mut b).is_empty(), "A still runs");
// A can't go on (its page lost its graphics): its copies stay, for the next app at once.
cmd::recovery::leave(&mut a);
assert!(a.doc().unwrap().recovery.is_none());
assert_eq!(copies(store.as_ref()), ["Untitled-1-1"]);
assert_eq!(offered(&mut b), ["Untitled-1"]);
assert_eq!(restored(b.execute("file.recovery.restore", &json!({})).unwrap()), 1);
}
}
#[test]
fn folder_areas_are_locked_by_running_apps() {
let dir = std::env::temp_dir().join(format!("vc-recovery-locks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let folder = dir.to_string_lossy().to_string();
let app = |dirty: bool| {
let mut s = Session::new();
s.prefs.recovery_folder = folder.clone();
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
if dirty {
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
}
s
};
let mut a = app(true);
a.execute("file.recovery.save", &json!({})).unwrap();
let area = a.recovery.own_area().unwrap().to_string();
assert!(dir.join(&area).join(".lock").is_file() && dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
// The lock file is held: another app (another handle) can't take it.
let store = FolderStore::new(&dir);
assert!(matches!(store.lock(&area), Ok(cmd::recovery::Lock::Busy)));
let mut b = app(false);
assert!(offered(&mut b).is_empty());
assert!(b.execute("file.recovery.discard", &json!({})).unwrap()["discarded"].as_array().unwrap().is_empty());
assert!(dir.join(&area).join("Untitled-1-1.vectorcraft").is_file());
// A crashes (its handle closes): B offers its copy, and restoring it tidies A's folder away.
drop(a);
assert_eq!(offered(&mut b), ["Untitled-1"]);
b.execute("file.recovery.restore", &json!({})).unwrap();
assert!(!dir.join(&area).exists(), "the crashed app's folder is removed");
let mine = b.recovery.own_area().unwrap().to_string();
assert!(dir.join(&mine).join("Untitled-1-1.vectorcraft").is_file());
// An empty folder a crash left (no copies) is tidied by the next list.
std::fs::create_dir_all(dir.join("123-1")).unwrap();
std::fs::write(dir.join("123-1").join(".lock"), b"").unwrap();
b.execute("file.recovery.list", &json!({})).unwrap();
assert!(!dir.join("123-1").exists());
// B quits with nothing unsaved: its folder goes too.
b.execute("file.close", &json!({})).unwrap();
b.execute("file.close", &json!({})).unwrap();
cmd::recovery::forget_all(&mut b);
assert!(!dir.join(&mine).exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn saving_reverting_or_closing_removes_the_copy() {
let dir = std::env::temp_dir().join(format!("vc-recovery-save-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("doc.vectorcraft").to_string_lossy().to_string();
// Save to a file.
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(copies(store.as_ref()).len(), 1);
s.execute("document.save", &json!({"path": path})).unwrap();
assert!(copies(store.as_ref()).is_empty(), "saved: the copy goes");
assert!(s.doc().unwrap().recovery.is_none());
// Revert.
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(copies(store.as_ref()).len(), 1);
s.execute("file.revert", &json!({})).unwrap();
assert!(copies(store.as_ref()).is_empty(), "reverted: the copy goes");
// Close (discarding the changes).
s.execute("shape.ellipse", &json!({"x": 100, "y": 10, "width": 20, "height": 20})).unwrap();
s.execute("file.recovery.save", &json!({})).unwrap();
s.execute("file.close", &json!({})).unwrap();
assert!(copies(store.as_ref()).is_empty(), "closed: the copy goes");
// A restored document's copy goes once it is saved.
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
drop(s);
let mut s2 = relaunch(&store);
assert_eq!(restored(s2.execute("file.recovery.restore", &json!({})).unwrap()), 1);
s2.execute("document.save", &json!({"path": path})).unwrap();
assert!(copies(store.as_ref()).is_empty());
let st = s2.doc().unwrap();
assert!(!st.recovered && !st.is_dirty());
assert_eq!(st.title(), "doc.vectorcraft");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn undoing_back_to_the_saved_state_drops_the_copy() {
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
s.execute("edit.undo", &json!({})).unwrap();
assert!(!s.doc().unwrap().is_dirty());
let r = s.execute("file.recovery.save", &json!({})).unwrap();
assert!(r["saved"].as_array().unwrap().is_empty() && r["skipped"].as_array().unwrap().is_empty(), "{r}");
assert!(copies(store.as_ref()).is_empty());
}
#[test]
fn complex_documents_are_skipped_when_the_preference_says() {
let store = Arc::new(MemoryStore::default());
let mut s = Session::new();
s.recovery.set_store(store.clone());
let mut d = vectorcraft_doc::Document::new(1000.0, 1000.0);
let layer = d.layers[0].id;
for i in 0..cmd::recovery::COMPLEX_OBJECTS {
let id = d.alloc_id();
let r = vectorcraft_geom::Rect::new((i % 200) as f64, (i / 200) as f64, (i % 200) as f64 + 1.0, (i / 200) as f64 + 1.0);
let n = vectorcraft_doc::Node::path(id, vectorcraft_geom::shapes::rectangle(r), vectorcraft_doc::Appearance::default_art());
d.insert(Some(layer), i, n).unwrap();
}
s.add_document(d, None);
// One more object makes it complex (and modified).
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.prefs.recovery_off_for_complex = true;
let r = s.execute("file.recovery.save", &json!({})).unwrap();
assert!(r["saved"].as_array().unwrap().is_empty());
assert!(r["skipped"][0]["reason"].as_str().unwrap().starts_with("complex document"), "{r}");
assert!(store.list().unwrap().is_empty(), "no copy, no area");
s.prefs.recovery_off_for_complex = false;
assert_eq!(s.execute("file.recovery.save", &json!({})).unwrap()["saved"].as_array().unwrap().len(), 1);
}
#[test]
fn discard_deletes_copies_left_behind_only() {
let (mut s, store) = session();
s.execute("file.recovery.save", &json!({})).unwrap();
let crashed = s.recovery.own_area().unwrap().to_string();
drop(s);
let mut s = relaunch(&store);
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.execute("file.recovery.save", &json!({})).unwrap();
assert_eq!(copies(store.as_ref()).len(), 2);
assert!(s.execute("file.recovery.discard", &json!({"file": "nope"})).is_err());
// The open document's copy stays.
let r = s.execute("file.recovery.discard", &json!({})).unwrap();
assert_eq!(r["discarded"], json!([format!("{crashed}/Untitled-1-1")]));
let mine = s.recovery.own_area().unwrap();
assert_eq!(store.list().unwrap(), [format!("{mine}/Untitled-1-1.json"), format!("{mine}/Untitled-1-1.vectorcraft")]);
}
#[test]
fn without_a_store_the_commands_say_where_to_set_one() {
let mut s = Session::new();
let e = s.execute("file.recovery.list", &json!({})).unwrap_err().to_string();
assert!(e.contains("recoveryFolder"), "{e}");
// The preference names a folder: copies go there.
let dir = std::env::temp_dir().join(format!("vc-recovery-folder-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
s.prefs.recovery_folder = dir.to_string_lossy().to_string();
assert!(s.execute("file.recovery.list", &json!({})).unwrap()["copies"].as_array().unwrap().is_empty(), "no folder yet: no copies");
s.execute("file.new", &json!({"width": 50, "height": 50})).unwrap();
s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 5, "height": 5})).unwrap();
s.execute("file.recovery.save", &json!({})).unwrap();
let area = dir.join(s.recovery.own_area().unwrap());
assert!(area.join("Untitled-1-1.vectorcraft").is_file() && area.join("Untitled-1-1.json").is_file());
s.execute("file.close", &json!({})).unwrap();
assert!(!area.join("Untitled-1-1.vectorcraft").exists());
let _ = std::fs::remove_dir_all(&dir);
}