collective

Browse / github.com/storytold/cadcraft / patch

Tests remove the temp files they write

Tests remove the temp files they write Every `cargo test --workspace` run leaves about 22 folders and files in the system temp directory. On a dev machine they pile up between reboots. This PR makes the tests delete what they write. It also fixes the PDFBOOK test, which wrote a file outside its own temp names. ## Changes **1. A `TempDir` guard for tests that write files** - `crates/engine/tests/support/temp_dir.rs` creates a folder named `cadcraft-<tag>-<pid>-<n>` and removes it on drop, even when an assertion fails. The counter gives every test its own folder, so a test never deletes files another test in the same binary is sti

Status
open
Score
0.00
Adopters
0 other operators
Author
bmh129 (imported from pull request #2232, open)
Base
d3ae8a6af0b76185e33f6dc2735679fa5c2be77c
Size
+212 −92 in 14 files
Review
pending
Shared
2026-10-28
Id
33e7289130966f1f79bec5fb

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/exchange.rs crates/engine/src/cmd/file.rs crates/engine/src/lib.rs crates/engine/tests/aliases.rs crates/engine/tests/insert_file.rs crates/engine/tests/lin_pat_files.rs crates/engine/tests/support/temp_dir.rs crates/engine/tests/templates.rs crates/engine/tests/xref.rs crates/ui-egui/src/capture.rs crates/ui-egui/src/control.rs crates/ui-egui/tests/recent_files.rs crates/ui-egui/tests/support/temp_dir.rs crates/ui-egui/tests/xref_palette.rs

diff --git a/crates/engine/src/cmd/exchange.rs b/crates/engine/src/cmd/exchange.rs--- a/crates/engine/src/cmd/exchange.rs+++ b/crates/engine/src/cmd/exchange.rs@@ -754,19 +754,20 @@ mod tests {         s.cmdline("").unwrap();         s.cmdline("pline 0,900 50,900 50,950 0,950 c").unwrap();         s.cmdline("").unwrap();-        let r = s.execute("pdfbook", &json!({ "path": temp("a^b.pdf").to_string_lossy() }));+        // `a^b.pdf` names one book per row, both in the folder of the path.+        let dir = crate::temp_dir::TempDir::new("pdfbook");+        let r = s.execute("pdfbook", &json!({ "path": dir.path_str("a^b.pdf") }));         let r = r.unwrap();         let books = r["books"].as_array().unwrap();         assert_eq!(books.len(), 2);         assert_eq!(books[0]["pages"], 3);         assert_eq!(books[1]["pages"], 1);-        let first = temp("a.pdf");+        let first = dir.join("a.pdf");         let text = String::from_utf8(std::fs::read(&first).unwrap()).unwrap();         let (a4, a3) = (text.find("ISO A4").unwrap(), text.find("ISO A3").unwrap());         assert!(a4 < a3, "frames go left to right: {text}");         assert!(text.contains("\"landscape\":true"));-        let _ = std::fs::remove_file(first);-        let _ = std::fs::remove_file(temp("b.pdf"));+        assert!(dir.join("b.pdf").is_file(), "the second row's book is written beside the first");         // No frames: an error, not a panic.         assert!(Session::new().execute("pdfbook", &json!({})).is_err());     }diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs--- a/crates/engine/src/cmd/file.rs+++ b/crates/engine/src/cmd/file.rs@@ -651,20 +651,16 @@ mod tests {     use serde_json::json;      use crate::Session;+    use crate::temp_dir::TempDir;      fn active_title(s: &Session) -> String {         s.state().unwrap().title.clone()     } -    fn temp_script(name: &str, text: &str) -> String {-        let path = std::env::temp_dir().join(format!("cadcraft-script-{}-{name}", std::process::id()));-        std::fs::write(&path, text).unwrap();-        path.to_string_lossy().into_owned()-    }-     #[test]     fn script_command_runs_a_scr_file() {-        let path = temp_script("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");+        let dir = TempDir::new("script");+        let path = dir.file("run.scr", "; a comment\ncircle 50,25 20\nrectang 0,0 100,60\n");         let mut s = Session::new();         let r = s.execute("script", &json!({ "path": path })).unwrap();         assert_eq!(r["lines"], 3);@@ -682,8 +678,9 @@ mod tests {     /// A script that runs itself must be refused, not recurse until the stack dies.     #[test]     fn a_script_cannot_start_a_script() {-        let inner = temp_script("inner.scr", "circle 0,0 1\n");-        let outer = temp_script("outer.scr", &format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));+        let dir = TempDir::new("script");+        let inner = dir.file("inner.scr", "circle 0,0 1\n");+        let outer = dir.file("outer.scr", format!("script {}\ncircle 0,0 2\n", json!({ "path": inner })));         let mut s = Session::new();         let e = s.execute("script", &json!({ "path": outer })).unwrap_err().to_string();         assert!(e.contains("already running"), "{e}");@@ -691,7 +688,8 @@ mod tests {      #[test]     fn typed_script_prompts_for_the_file_then_runs_or_reports_it() {-        let good = temp_script("typed.scr", "circle 0,0 1\n");+        let dir = TempDir::new("script");+        let good = dir.file("typed.scr", "circle 0,0 1\n");         let mut s = Session::new();         s.cmdline("script").unwrap();         assert!(s.running.is_some(), "SCRIPT asks for the file name");diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -1935,3 +1935,7 @@ fn next_token(rest: &str) -> (&str, Option<&str>) {  #[cfg(test)] mod tests;++#[cfg(test)]+#[path = "../tests/support/temp_dir.rs"]+mod temp_dir;diff --git a/crates/engine/tests/aliases.rs b/crates/engine/tests/aliases.rs--- a/crates/engine/tests/aliases.rs+++ b/crates/engine/tests/aliases.rs@@ -8,6 +8,11 @@ use cadcraft_engine::Session; use cadcraft_engine::cmd::resolve_alias; use serde_json::json; +#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;+ static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());  const PGP: &str = "\@@ -55,10 +60,8 @@ fn an_alias_file_adds_and_overrides_aliases() { #[test] fn reinit_reads_the_alias_file_again() {     let _one = ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner());-    let dir = std::env::temp_dir().join(format!("cadcraft-aliases-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    let file = dir.join("mine.pgp").to_string_lossy().into_owned();-    std::fs::write(&file, "ZZR, *RECTANG\n").unwrap();+    let dir = TempDir::new("aliases");+    let file = dir.file("mine.pgp", "ZZR, *RECTANG\n");     let mut s = Session::new();     s.execute("aliasedit", &json!({ "file": file })).unwrap();     assert_eq!(resolve_alias("zzr"), "rectang");diff --git a/crates/engine/tests/insert_file.rs b/crates/engine/tests/insert_file.rs--- a/crates/engine/tests/insert_file.rs+++ b/crates/engine/tests/insert_file.rs@@ -8,6 +8,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, HVal, Handle, Insert, Layer, Line}; use serde_json::json; +#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;+ /// A drawing file in a test format: its bytes are "x,y", the $INSBASE. Model space: a line from /// 10,10 to 20,10 on layer PARTS (colour 1), and a reference to block NUT (a circle) at 10,10. fn read(bytes: &[u8], _name: &str) -> Result<Drawing, String> {@@ -44,15 +49,6 @@ fn hooks() {     set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None }); } -/// Write a test drawing file with $INSBASE `base`; returns its path.-fn file(name: &str, base: &str) -> String {-    let dir = std::env::temp_dir().join(format!("cadcraft-insert-file-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    let p = dir.join(name);-    std::fs::write(&p, base).unwrap();-    p.to_string_lossy().into_owned()-}- /// Lines in model space as (ax, ay, bx, by), rounded. fn lines(s: &Session) -> Vec<(f64, f64, f64, f64)> {     let r = |v: f64| (v * 1e6).round() / 1e6;@@ -105,7 +101,9 @@ fn star_name_inserts_the_blocks_objects_with_one_scale_factor() { #[test] fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {     hooks();-    let bolt = file("bolt.dxf", "10,10");+    // Test drawing files hold their $INSBASE.+    let dir = TempDir::new("insert-file");+    let bolt = dir.file("bolt.dxf", "10,10");     let mut s = Session::new();     // This drawing's own layer PARTS keeps its colour.     s.execute("layer.new", &json!({ "name": "PARTS", "color": 3 })).unwrap();@@ -128,7 +126,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {     assert_eq!(inserts(&s).len(), 2);      // NAME=path defines the block under that name without asking; *path inserts the file's objects.-    let pin = file("pin.dxf", "10,10");+    let pin = dir.file("pin.dxf", "10,10");     s.script(&format!("-INSERT M8={bolt} 0,0 1 1 0\n-INSERT *{pin} 50,0 1 0\n")).unwrap();     assert!(s.running.is_none(), "INSERT finished: {}", s.prompt_text());     assert!(s.doc().unwrap().block("M8").is_some());@@ -138,7 +136,7 @@ fn a_drawing_file_becomes_a_block_with_its_insbase_and_tables() {     assert!(inserts(&s).iter().any(|i| i.0 == "NUT" && i.1 == 50.0 && i.2 == 0.0));      // JSON: `file` with an optional block name, never a dialog.-    let r = s.execute("insert", &json!({ "file": file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();+    let r = s.execute("insert", &json!({ "file": dir.file("washer.dxf", "0,0"), "at": [5, 5] })).unwrap();     assert!(r["handle"].is_string(), "{r}");     assert!(inserts(&s).iter().any(|i| i.0 == "washer")); diff --git a/crates/engine/tests/lin_pat_files.rs b/crates/engine/tests/lin_pat_files.rs--- a/crates/engine/tests/lin_pat_files.rs+++ b/crates/engine/tests/lin_pat_files.rs@@ -6,16 +6,12 @@ use cadcraft_engine::Session; use cadcraft_engine::doc::{EntityKind, Hatch}; use serde_json::json; -const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";+#[path = "support/temp_dir.rs"]+mod temp_dir; -/// A user file in the test's temp folder; returns its path.-fn file(name: &str, text: &str) -> String {-    let dir = std::env::temp_dir().join(format!("cadcraft-lin-pat-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    let p = dir.join(name);-    std::fs::write(&p, text).unwrap();-    p.to_string_lossy().into_owned()-}+use temp_dir::TempDir;++const LIN: &str = "*MYDASH,My dash __ __\nA,.5,-.25\n*MYTEXT,My text --MY--\nA,.5,-.2,[\"MY\",Standard,S=.1,X=-.1,Y=-.05],-.2\n";  fn linetype_pattern(s: &Session, name: &str) -> Option<Vec<f64>> {     s.doc().unwrap().linetype(name).map(|l| l.pattern.iter().map(|e| e.length).collect())@@ -46,7 +42,8 @@ fn linetype_json_loads_named_linetypes_from_file_text() {  #[test] fn linetype_json_reads_a_lin_file_by_path() {-    let path = file("mine.lin", LIN);+    let dir = TempDir::new("lin-pat");+    let path = dir.file("mine.lin", LIN);     let mut s = Session::new();     s.execute("linetype", &json!({ "load": "MYTEXT", "file": path })).unwrap();     assert!(linetype_pattern(&s, "MYTEXT").is_some());@@ -58,7 +55,8 @@ fn linetype_json_reads_a_lin_file_by_path() {  #[test] fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {-    let path = file("cmdline.lin", LIN);+    let dir = TempDir::new("lin-pat");+    let path = dir.file("cmdline.lin", LIN);     let mut s = Session::new();     s.cmdline("-linetype").unwrap();     for line in ["l", "MYDASH,MYTEXT", path.as_str(), "s", "MYTEXT"] {@@ -72,7 +70,7 @@ fn dash_linetype_loads_from_a_file_and_the_library_at_the_command_line() {     assert!(s.log.iter().any(|l| l.contains("Linetype MYDASH loaded")), "{:?}", s.log);      // Already loaded: asked whether to reload, in the file's order.-    let changed = file("changed.lin", &LIN.replace(".5,-.25", "2,-2"));+    let changed = dir.file("changed.lin", LIN.replace(".5,-.25", "2,-2"));     s.cmdline("-linetype").unwrap();     for line in ["l", "*", changed.as_str()] {         s.cmdline(line).unwrap();@@ -132,7 +130,8 @@ fn hatch_uses_a_pattern_from_a_pat_file_and_keeps_its_definition() {  #[test] fn hatch_pattern_prompt_takes_a_pat_file_name() {-    let path = file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");+    let dir = TempDir::new("lin-pat");+    let path = dir.file("MYSTAIRS.pat", "*MYOTHER\n0,0,0,0,1\n*MYSTAIRS,Stairs\n0,0,0,.5,.5,.5,-.5\n90,.5,0,.5,.5,.5,-.5\n");     let mut s = Session::new();     s.execute("rectang", &json!({ "p1": [0, 0], "p2": [10, 10] })).unwrap();     s.cmdline("hatch").unwrap();diff --git a/crates/engine/tests/support/temp_dir.rs b/crates/engine/tests/support/temp_dir.rsnew file mode 100644--- /dev/null+++ b/crates/engine/tests/support/temp_dir.rs@@ -0,0 +1,46 @@+//! A test's own folder in the system temp directory, removed when the test ends, even when an+//! assertion fails. Shared by the integration tests (`#[path]`) and the crate's unit tests.+#![allow(dead_code)]++use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicU64, Ordering};++/// `cadcraft-<tag>-<pid>-<n>`: the process id keeps parallel test binaries apart and the counter+/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder.+pub struct TempDir(PathBuf);++impl TempDir {+    pub fn new(tag: &str) -> Self {+        static NEXT: AtomicU64 = AtomicU64::new(0);+        let n = NEXT.fetch_add(1, Ordering::Relaxed);+        let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).expect("create the test's temp folder");+        Self(dir)+    }++    /// Write `contents` to `name` in the folder; returns the file's path as text.+    pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String {+        let p = self.0.join(name);+        std::fs::write(&p, contents).expect("write a test file");+        p.to_string_lossy().into_owned()+    }++    /// The path of `name` in the folder as text, without creating it.+    pub fn path_str(&self, name: &str) -> String {+        self.0.join(name).to_string_lossy().into_owned()+    }+}++impl std::ops::Deref for TempDir {+    type Target = Path;+    fn deref(&self) -> &Path {+        &self.0+    }+}++impl Drop for TempDir {+    fn drop(&mut self) {+        let _ = std::fs::remove_dir_all(&self.0);+    }+}diff --git a/crates/engine/tests/templates.rs b/crates/engine/tests/templates.rs--- a/crates/engine/tests/templates.rs+++ b/crates/engine/tests/templates.rs@@ -9,6 +9,11 @@ use cadcraft_engine::cmd::file::{IoHooks, set_io}; use cadcraft_engine::doc::Drawing; use serde_json::json; +#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;+ static FILES: Mutex<Vec<(String, Drawing)>> = Mutex::new(Vec::new());  fn write(d: &Drawing, name: &str) -> Result<Vec<u8>, String> {@@ -27,17 +32,11 @@ fn hooks() {     set_io(IoHooks { read, write, plot: None }); } -fn path(name: &str) -> String {-    let dir = std::env::temp_dir().join(format!("cadcraft-templates-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    dir.join(name).to_string_lossy().into_owned()-}- /// A drawing with an office layer and a title block circle, saved as a template.-fn office_template(s: &mut Session, name: &str) -> String {+fn office_template(s: &mut Session, dir: &TempDir, name: &str) -> String {     s.execute("layer.new", &json!({ "name": "A-WALL", "color": 3 })).unwrap();     s.execute("circle", &json!({ "center": [5, 5], "radius": 2 })).unwrap();-    let p = path(name);+    let p = dir.path_str(name);     s.execute("saveas", &json!({ "path": p })).unwrap();     p }@@ -46,7 +45,8 @@ fn office_template(s: &mut Session, name: &str) -> String { fn saveas_dwt_then_new_from_the_template() {     hooks();     let mut s = Session::new();-    let dwt = office_template(&mut s, "office.dwt");+    let dir = TempDir::new("templates");+    let dwt = office_template(&mut s, &dir, "office.dwt");     // A template is saved like a drawing: the format sees the .dwt name, the drawing is saved.     assert!(FILES.lock().unwrap().iter().any(|(n, _)| n.ends_with("office.dwt")));     let st = s.state().unwrap();@@ -68,7 +68,8 @@ fn saveas_dwt_then_new_from_the_template() { fn qnewtemplate_is_the_default_for_new_and_qnew() {     hooks();     let mut s = Session::new();-    let dwt = office_template(&mut s, "default.dwt");+    let dir = TempDir::new("templates");+    let dwt = office_template(&mut s, &dir, "default.dwt");     let docs = s.docs.len();      s.execute("setvar", &json!({ "name": "QNEWTEMPLATE", "value": dwt })).unwrap();@@ -99,8 +100,8 @@ fn a_template_that_cannot_be_read_makes_no_drawing() {     let docs = s.docs.len();     let err = s.execute("new", &json!({ "template": "/no/such/folder/office.dwt" })).unwrap_err().to_string();     assert!(err.contains("office.dwt"), "{err}");-    let junk = path("junk.dwt");-    std::fs::write(&junk, b"not a drawing at all").unwrap();+    let dir = TempDir::new("templates");+    let junk = dir.file("junk.dwt", b"not a drawing at all");     assert!(s.execute("new", &json!({ "template": junk })).is_err());     assert!(s.execute("new", &json!({ "template": 42 })).is_err());     assert_eq!(s.docs.len(), docs);diff --git a/crates/engine/tests/xref.rs b/crates/engine/tests/xref.rs--- a/crates/engine/tests/xref.rs+++ b/crates/engine/tests/xref.rs@@ -2,7 +2,7 @@ //! loading with `xref|name` symbols, saving only the reference, reopening, Reload/Unload/Detach/ //! Path, Bind and XBIND, overlays, missing files, circular and deeply nested references. -use std::path::{Path, PathBuf};+use std::path::Path; use std::sync::Arc;  use cadcraft_engine::Session;@@ -12,6 +12,11 @@ use cadcraft_engine::doc::geom::Vec3; use cadcraft_engine::doc::{Block, Circle, Drawing, Entity, EntityKind, Handle, Insert, Layer, Line, Linetype, MAX_BLOCK_DEPTH, XrefStatus}; use serde_json::json; +#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;+ fn hooks() {     set_io(IoHooks {         read: |b, name| cadcraft_io::read(b, name).map_err(|e| e.to_string()),@@ -20,12 +25,9 @@ fn hooks() {     }); } -/// A fresh folder for one test.-fn folder(tag: &str) -> PathBuf {-    let dir = std::env::temp_dir().join(format!("cadcraft-xref-{}-{tag}", std::process::id()));-    let _ = std::fs::remove_dir_all(&dir);-    std::fs::create_dir_all(&dir).unwrap();-    dir+/// A fresh folder for one test, removed when the test ends.+fn folder(tag: &str) -> TempDir {+    TempDir::new(&format!("xref-{tag}")) }  fn save(d: &Drawing, path: &Path) {diff --git a/crates/ui-egui/src/capture.rs b/crates/ui-egui/src/capture.rs--- a/crates/ui-egui/src/capture.rs+++ b/crates/ui-egui/src/capture.rs@@ -744,6 +744,7 @@ mod tests {     /// Two requests issue from one settled frame; each reply carries its own token and a distinct, readable file with a PNG signature.     #[test]     fn issued_callbacks_reply_once_with_matching_tokens_and_distinct_pathless_files() {+        let _shots = crate::control::lock_pathless_shots();         let ctx = egui::Context::default();         let mut app = app();         set_geometry(&mut app, &ctx, egui::vec2(800.0, 600.0));@@ -766,8 +767,8 @@ mod tests {         assert_ne!(a, b, "one file per request");         for path in [&a, &b] {             assert!(std::fs::read(path).is_ok_and(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")), "{path}");-            let _ = std::fs::remove_file(path);         }+        crate::control::remove_pathless_shots([&a, &b]);         assert!(app.shots.is_empty());     } @@ -918,6 +919,7 @@ mod tests {     /// not issue, and the settled second pass must issue exactly once.     #[test]     fn a_discarded_pass_does_not_issue_and_the_settled_pass_does_once() {+        let _shots = crate::control::lock_pathless_shots();         let ctx = egui::Context::default();         let mut app = app();         let (rx, _) = admit_one(&mut app, &ctx);@@ -941,13 +943,16 @@ mod tests {         assert_eq!(app.ui_frames, 1, "one settled frame, not one per pass");         complete(callbacks, [800, 600]);         drain(&mut app, &ctx);-        assert_eq!(rx.recv().expect("a reply")["ok"], true);+        let reply = rx.recv().expect("a reply");+        assert_eq!(reply["ok"], true, "{reply}");+        crate::control::remove_pathless_shots(reply["result"]["path"].as_str());     }      /// Issuance re-bases the expected image on the settled pass, so the saved interface zoom     /// (applied by egui at the start of a later pass) cannot leave a stale admission estimate.     #[test]     fn issuance_uses_the_settled_interface_scale_not_the_admission_estimate() {+        let _shots = crate::control::lock_pathless_shots();         let ctx = egui::Context::default();         let mut app = app();         app.options.interface_scale = 2.0;@@ -963,8 +968,7 @@ mod tests {         drain(&mut app, &ctx);         let reply = rx.recv().expect("a reply");         assert_eq!(reply["ok"], true, "{reply}");-        let path = reply["result"]["path"].as_str().expect("a file").to_string();-        let _ = std::fs::remove_file(path);+        crate::control::remove_pathless_shots(reply["result"]["path"].as_str());     }      /// A→B→A: a switch that returns to the same drawing still invalidates the queued request.@@ -1023,6 +1027,7 @@ mod tests {     /// A window resized while the request is still settling is issued against the new size.     #[test]     fn a_resize_while_settling_is_absorbed_at_issue() {+        let _shots = crate::control::lock_pathless_shots();         let ctx = egui::Context::default();         let mut app = app();         let (rx, _) = admit_one(&mut app, &ctx);@@ -1040,7 +1045,6 @@ mod tests {         drain(&mut app, &ctx);         let reply = rx.recv().expect("a reply");         assert_eq!(reply["ok"], true, "{reply}");-        let path = reply["result"]["path"].as_str().expect("a file").to_string();-        let _ = std::fs::remove_file(path);+        crate::control::remove_pathless_shots(reply["result"]["path"].as_str());     } }diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs--- a/crates/ui-egui/src/control.rs+++ b/crates/ui-egui/src/control.rs@@ -619,6 +619,26 @@ fn is_private_shot_dir(meta: &std::fs::Metadata) -> bool {     } } +/// Pathless screenshots share one directory per process. Tests that write them hold this lock,+/// so removing the directory afterwards never races another test's write.+#[cfg(test)]+pub(crate) fn lock_pathless_shots() -> std::sync::MutexGuard<'static, ()> {+    static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());+    LOCK.lock().unwrap_or_else(std::sync::PoisonError::into_inner)+}++/// Test cleanup: remove pathless screenshot files, then their directory once it is empty.+#[cfg(test)]+pub(crate) fn remove_pathless_shots<P: AsRef<std::path::Path>>(paths: impl IntoIterator<Item = P>) {+    for path in paths {+        let path = path.as_ref();+        let _ = std::fs::remove_file(path);+        if let Some(dir) = path.parent() {+            let _ = std::fs::remove_dir(dir);+        }+    }+}+ /// Save a pathless screenshot into the private per-process directory: re-validate or recreate it as an owner-only real directory (0700 on Unix; plain create elsewhere), then create up to 64 exclusive files (0600 on Unix) and write a PNG. fn save_pathless_screenshot(img: &image::RgbaImage, token: u64) -> std::io::Result<std::path::PathBuf> {     use std::io::Write as _;@@ -735,6 +755,7 @@ mod tests {     /// Two pathless saves with the same token produce distinct, decodable PNGs; on Unix the files are 0600 and their directory 0700.     #[test]     fn pathless_screenshots_are_unique_private_png_files() {+        let _shots = lock_pathless_shots();         let img = egui::ColorImage::filled([4, 3], egui::Color32::RED);         let first = save_screenshot(&img, None, 1);         let second = save_screenshot(&img, None, 1);@@ -759,9 +780,7 @@ mod tests {             let dir_mode = std::fs::metadata(dir).expect("directory metadata").permissions().mode() & 0o777;             assert_eq!(dir_mode, 0o700, "the screenshot directory is owner-only");         }-        for path in [&a, &b] {-            let _ = std::fs::remove_file(path);-        }+        remove_pathless_shots([&a, &b]);     }      /// `create_new` refuses a pre-existing symlink with `AlreadyExists` and leaves the link target unchanged.diff --git a/crates/ui-egui/tests/recent_files.rs b/crates/ui-egui/tests/recent_files.rs--- a/crates/ui-egui/tests/recent_files.rs+++ b/crates/ui-egui/tests/recent_files.rs@@ -9,17 +9,18 @@ use cadcraft_ui_egui::{CadApp, Services}; use egui::{Rect, Shape, vec2}; use egui_kittest::Harness; -/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter).-fn drawings(test: &str) -> (String, String) {+#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;++/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter),+/// in a folder that is removed when the returned guard is dropped.+fn drawings(test: &str) -> (TempDir, String, String) {     set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(Vec::new()), plot: None });-    let dir = std::env::temp_dir().join(format!("cadcraft-recent-{test}-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    let path = |name: &str| {-        let p = dir.join(name);-        std::fs::write(&p, b"0\nEOF\n").unwrap();-        p.to_string_lossy().to_string()-    };-    (path("plan.dxf"), path("section.dwg"))+    let dir = TempDir::new(&format!("recent-{test}"));+    let (plan, section) = (dir.file("plan.dxf", b"0\nEOF\n"), dir.file("section.dwg", b"0\nEOF\n"));+    (dir, plan, section) }  /// Wait for the drawings being read in the background to be added (or reported).@@ -49,7 +50,7 @@ fn open_recent(app: &CadApp) -> Vec<(String, String)> {  #[test] fn opened_drawings_are_listed_newest_first_and_survive_a_restart() {-    let (plan, section) = drawings("menu");+    let (_dir, plan, section) = drawings("menu");     let mut app = CadApp::new(Session::empty(), Services::default());     assert!(app.ui.recent_files.is_empty()); @@ -99,7 +100,7 @@ fn texts(shape: &Shape, out: &mut Vec<(String, Rect)>) {  #[test] fn start_page_lists_recent_files_and_opens_one_on_click() {-    let (plan, section) = drawings("start");+    let (_dir, plan, section) = drawings("start");     let mut app = CadApp::new(Session::empty(), Services::default());     app.ui.recent_files = vec![section.clone(), plan.clone()];     let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_ui_state(diff --git a/crates/ui-egui/tests/support/temp_dir.rs b/crates/ui-egui/tests/support/temp_dir.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/tests/support/temp_dir.rs@@ -0,0 +1,46 @@+//! A test's own folder in the system temp directory, removed when the test ends, even when an+//! assertion fails. Shared by the integration tests through `#[path]`.+#![allow(dead_code)]++use std::path::{Path, PathBuf};+use std::sync::atomic::{AtomicU64, Ordering};++/// `cadcraft-<tag>-<pid>-<n>`: the process id keeps parallel test binaries apart and the counter+/// keeps the tests of one binary apart, so no two tests ever share (or delete) one folder.+pub struct TempDir(PathBuf);++impl TempDir {+    pub fn new(tag: &str) -> Self {+        static NEXT: AtomicU64 = AtomicU64::new(0);+        let n = NEXT.fetch_add(1, Ordering::Relaxed);+        let dir = std::env::temp_dir().join(format!("cadcraft-{tag}-{}-{n}", std::process::id()));+        let _ = std::fs::remove_dir_all(&dir);+        std::fs::create_dir_all(&dir).expect("create the test's temp folder");+        Self(dir)+    }++    /// Write `contents` to `name` in the folder; returns the file's path as text.+    pub fn file(&self, name: &str, contents: impl AsRef<[u8]>) -> String {+        let p = self.0.join(name);+        std::fs::write(&p, contents).expect("write a test file");+        p.to_string_lossy().into_owned()+    }++    /// The path of `name` in the folder as text, without creating it.+    pub fn path_str(&self, name: &str) -> String {+        self.0.join(name).to_string_lossy().into_owned()+    }+}++impl std::ops::Deref for TempDir {+    type Target = Path;+    fn deref(&self) -> &Path {+        &self.0+    }+}++impl Drop for TempDir {+    fn drop(&mut self) {+        let _ = std::fs::remove_dir_all(&self.0);+    }+}diff --git a/crates/ui-egui/tests/xref_palette.rs b/crates/ui-egui/tests/xref_palette.rs--- a/crates/ui-egui/tests/xref_palette.rs+++ b/crates/ui-egui/tests/xref_palette.rs@@ -11,6 +11,11 @@ use egui_kittest::Harness; use egui_kittest::kittest::Queryable; use serde_json::json; +#[path = "support/temp_dir.rs"]+mod temp_dir;++use temp_dir::TempDir;+ /// Test drawings: any file holds one line on layer Walls. fn read(_bytes: &[u8], _name: &str) -> Result<Drawing, String> {     let mut d = Drawing::new_metric();@@ -21,14 +26,6 @@ fn read(_bytes: &[u8], _name: &str) -> Result<Drawing, String> {     Ok(d) } -fn site_file() -> String {-    let dir = std::env::temp_dir().join(format!("cadcraft-xref-palette-{}", std::process::id()));-    std::fs::create_dir_all(&dir).unwrap();-    let p = dir.join("site.dxf");-    std::fs::write(&p, "site").unwrap();-    p.to_string_lossy().into_owned()-}- fn status(app: &CadApp) -> Option<XrefStatus> {     app.session.doc().ok()?.block("site").map(|b| b.xref_status) }@@ -37,7 +34,8 @@ fn status(app: &CadApp) -> Option<XrefStatus> { fn xref_opens_the_palette_and_its_buttons_manage_the_xref() {     set_io(IoHooks { read, write: |_, _| Err("no writer in this test".into()), plot: None });     let mut app = CadApp::new(Session::default(), Services::default());-    app.run("xattach", json!({ "path": site_file(), "at": [10, 10] })).unwrap();+    let dir = TempDir::new("xref-palette");+    app.run("xattach", json!({ "path": dir.file("site.dxf", "site"), "at": [10, 10] })).unwrap();     assert_eq!(status(&app), Some(XrefStatus::Loaded));     // Typed (by its alias) it opens the palette instead of listing.     assert!(app.cmdline("XR").is_none());