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.rs
new 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.rs
new 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());
