From c4e625f0ec51d42f807702a9470b8d98589f5f08 Mon Sep 17 00:00:00 2001 From: Combiler01 Date: Sat, 17 Oct 2026 21:29:55 +0530 Subject: [PATCH] test: implement deterministic mock clipboard to resolve Windows heap corruption --- .github/workflows/ci.yml | 4 +- apps/pdfcraft/src/main.rs | 2 + crates/ui-egui/src/clipboard.rs | 38 +++++++++++++++++ crates/ui-egui/src/content_ui.rs | 71 +++++++++++++++++++++++--------- crates/ui-egui/src/create_ui.rs | 22 ++++++++-- crates/ui-egui/src/image_pick.rs | 2 +- crates/ui-egui/src/lib.rs | 47 ++++++++++++++++++--- crates/ui-egui/src/zoom_snap.rs | 13 ++++-- crates/ui-egui/tests/editing.rs | 26 ++++++++++-- 9 files changed, 186 insertions(+), 39 deletions(-) create mode 100644 crates/ui-egui/src/clipboard.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7eca1256b0..388b44f032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,11 +85,11 @@ jobs: - name: Test if: runner.os != 'Linux' - run: cargo test --workspace -- --test-threads=1 + run: cargo test --workspace - name: Test (with craft-fonts) if: runner.os == 'Linux' - run: cargo test --workspace -- --test-threads=1 + run: cargo test --workspace - name: Layering if: runner.os == 'Linux' diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index 7a0bb20432..5af9fa3fc0 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -368,6 +368,8 @@ fn app_creator<'a>( Box::new(move |cc| { started.set(true); let mut app = PdfCraftApp::new(); + app.system_clipboard = true; + app.clipboard_override = None; // First start (or settings from before Units existed): the region's unit. app.units = pdfcraft_ui_egui::units::Unit::system(); #[cfg(target_os = "linux")] diff --git a/crates/ui-egui/src/clipboard.rs b/crates/ui-egui/src/clipboard.rs new file mode 100644 index 0000000000..a30304fa66 --- /dev/null +++ b/crates/ui-egui/src/clipboard.rs @@ -0,0 +1,38 @@ +//! Deterministic in-memory clipboard for headless UI tests and automation. +//! +//! Prevents headless tests running in parallel from competing for the native +//! operating system clipboard, eliminating concurrency races and OS heap corruption. + +use std::path::PathBuf; + +/// A deterministic, in-memory mock clipboard for tests. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MockClipboard { + pub text: Option, + pub image: Option<(usize, usize, Vec)>, + pub files: Option>, +} + +impl MockClipboard { + /// Creates an empty mock clipboard. + pub fn new() -> Self { + Self::default() + } + + /// Creates a mock clipboard containing plain text. + pub fn with_text(text: impl Into) -> Self { + Self { text: Some(text.into()), image: None, files: None } + } + + /// Creates a mock clipboard containing an image (width, height, RGBA bytes). + pub fn with_image(width: usize, height: usize, rgba: Vec) -> Self { + Self { text: None, image: Some((width, height, rgba)), files: None } + } + + /// Clears all clipboard contents. + pub fn clear(&mut self) { + self.text = None; + self.image = None; + self.files = None; + } +} diff --git a/crates/ui-egui/src/content_ui.rs b/crates/ui-egui/src/content_ui.rs index 61be108256..c6d914d243 100644 --- a/crates/ui-egui/src/content_ui.rs +++ b/crates/ui-egui/src/content_ui.rs @@ -821,14 +821,19 @@ impl crate::PdfCraftApp { return false; }; let copied = pixels.and_then(|(w, h, rgba)| { - #[cfg(not(target_arch = "wasm32"))] - if self.system_clipboard { - let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) }; - arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; - } - #[cfg(target_arch = "wasm32")] - if self.system_clipboard { - return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string()); + if let Some(mock) = &mut self.clipboard_override { + mock.image = Some((w as usize, h as usize, rgba.clone())); + mock.text = None; + } else { + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&rgba) }; + arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + } + #[cfg(target_arch = "wasm32")] + if self.system_clipboard { + return Err("the web version can't put images on the clipboard yet; use Save Image As".to_string()); + } } self.last_copied_image = Some((w, h, rgba)); Ok((w, h)) @@ -895,11 +900,25 @@ impl crate::PdfCraftApp { /// Whether there is an image in the in-memory clipboard or system clipboard ready to paste. pub fn can_paste_image(&self) -> bool { + if let Some(mock) = &self.clipboard_override { + if mock.text.as_ref().is_some_and(|t| !t.is_empty()) { + return false; + } + if mock.image.is_some() { + return true; + } + return self.last_copied_image.is_some() || self.last_copied_file.is_some(); + } #[cfg(not(target_arch = "wasm32"))] { if self.system_clipboard && let Ok(mut cb) = arboard::Clipboard::new() { + if let Ok(text) = cb.get_text() + && !text.is_empty() + { + return false; + } return cb.get_image().is_ok(); } } @@ -918,19 +937,33 @@ impl crate::PdfCraftApp { return false; } - #[cfg(not(target_arch = "wasm32"))] - let (has_system_cb, os_image) = if self.system_clipboard { - if let Ok(mut cb) = arboard::Clipboard::new() { - let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec())); - (true, img_opt) - } else { - (false, None) - } + let (has_system_cb, os_image, system_has_text) = if let Some(mock) = &self.clipboard_override { + let has_text = mock.text.as_ref().is_some_and(|t| !t.is_empty()); + let img = mock.image.clone(); + (true, img, has_text) } else { - (false, None) + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + if let Ok(mut cb) = arboard::Clipboard::new() { + let has_text = cb.get_text().map(|t| !t.is_empty()).unwrap_or(false); + let img_opt = cb.get_image().ok().map(|img| (img.width, img.height, img.bytes.to_vec())); + (true, img_opt, has_text) + } else { + (false, None, false) + } + } else { + (false, None, false) + } + #[cfg(target_arch = "wasm32")] + (false, None, false) }; - #[cfg(target_arch = "wasm32")] - let (has_system_cb, os_image): (bool, Option<(usize, usize, Vec)>) = (false, None); + + if system_has_text { + self.last_copied_image = None; + self.last_copied_file = None; + self.last_copied_image_rect = None; + return false; + } let image_data: Option<(String, Vec)> = if has_system_cb { match os_image { diff --git a/crates/ui-egui/src/create_ui.rs b/crates/ui-egui/src/create_ui.rs index fb8f1b2e27..e1ecbbe1f1 100644 --- a/crates/ui-egui/src/create_ui.rs +++ b/crates/ui-egui/src/create_ui.rs @@ -111,10 +111,24 @@ impl PdfCraftApp { /// Create ▸ Clipboard: a new document from the image (one page, its size) or the text on /// the clipboard, as Acrobat does. pub(crate) fn create_from_clipboard(&mut self) { - #[cfg(not(target_arch = "wasm32"))] - let clip = read_clipboard(); - #[cfg(target_arch = "wasm32")] - let clip: Option = None; + let clip = if let Some(mock) = &self.clipboard_override { + if let Some((w, h, rgba)) = &mock.image { + Some(Clip::Image { width: *w, height: *h, rgba: rgba.clone() }) + } else { + mock.text.as_ref().map(|t| Clip::Text(t.clone())) + } + } else if self.system_clipboard { + #[cfg(not(target_arch = "wasm32"))] + { + read_clipboard() + } + #[cfg(target_arch = "wasm32")] + { + None + } + } else { + None + }; match clip { Some(c) => { if let Err(e) = self.create_from_clip(c) { diff --git a/crates/ui-egui/src/image_pick.rs b/crates/ui-egui/src/image_pick.rs index 9e117395cd..9be0a8efc3 100644 --- a/crates/ui-egui/src/image_pick.rs +++ b/crates/ui-egui/src/image_pick.rs @@ -203,7 +203,7 @@ impl PdfCraftApp { /// previous copy, and puts that file on the system clipboard. #[cfg(not(target_arch = "wasm32"))] fn put_file_on_clipboard(&self, name: &str, bytes: &[u8]) -> Result<(), String> { - if !self.system_clipboard { + if self.clipboard_override.is_some() || !self.system_clipboard { return Ok(()); } let dir = clipboard_dir().ok_or("there is no folder for the copied file")?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ba8fd6390d..bc5970aef5 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ mod combine_ui; pub mod docking; pub use combine_ui::{Columns as CombineColumns, CombineView, Lock as CombineLock, SortKey}; mod camera_ui; +pub mod clipboard; +pub use clipboard::MockClipboard; mod commands; mod comment_props; pub mod comments; @@ -732,6 +734,9 @@ pub struct PdfCraftApp { pub last_copied_file: Option<(String, Vec)>, pub last_copied_image_rect: Option<[f64; 4]>, pub system_clipboard: bool, + /// Deterministic in-memory clipboard for headless UI tests and automation. + /// When `Some`, clipboard operations use this mock instead of the OS clipboard. + pub clipboard_override: Option, /// Attach file: the file to attach instead of asking (tests, automation). pub attach_override: Option<(String, Vec)>, /// Duplicate Field: which field and onto which pages. @@ -1032,7 +1037,8 @@ impl PdfCraftApp { last_copied_image: None, last_copied_file: None, last_copied_image_rect: None, - system_clipboard: true, + system_clipboard: false, + clipboard_override: Some(MockClipboard::default()), attach_override: None, duplicate_draft: None, form_preview: false, @@ -2508,6 +2514,31 @@ impl PdfCraftApp { } } + /// Injects a deterministic mock clipboard for headless tests. + pub fn set_mock_clipboard(&mut self, mock: MockClipboard) { + self.clipboard_override = Some(mock); + } + + /// Sets plain text on the mock clipboard (creating one if absent). + pub fn set_mock_clipboard_text(&mut self, text: impl Into) { + if let Some(mock) = &mut self.clipboard_override { + mock.text = Some(text.into()); + mock.image = None; + } else { + self.clipboard_override = Some(MockClipboard::with_text(text)); + } + } + + /// Sets an image on the mock clipboard (creating one if absent). + pub fn set_mock_clipboard_image(&mut self, width: usize, height: usize, rgba: Vec) { + if let Some(mock) = &mut self.clipboard_override { + mock.image = Some((width, height, rgba)); + mock.text = None; + } else { + self.clipboard_override = Some(MockClipboard::with_image(width, height, rgba)); + } + } + /// `true` while any open document still waits for page renders (used by headless capture). pub fn render_pending(&self) -> bool { self.views.iter().any(|v| v.render_pending()) @@ -2950,14 +2981,18 @@ impl PdfCraftApp { self.cut_page_image(sel.page, sel.index); } if paste { - #[cfg(not(target_arch = "wasm32"))] - let system_has_text = if self.system_clipboard { - arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false) + let system_has_text = if let Some(mock) = &self.clipboard_override { + mock.text.as_ref().is_some_and(|t| !t.is_empty()) } else { + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + arboard::Clipboard::new().and_then(|mut cb| cb.get_text()).map(|t| !t.is_empty()).unwrap_or(false) + } else { + false + } + #[cfg(target_arch = "wasm32")] false }; - #[cfg(target_arch = "wasm32")] - let system_has_text = false; if paste_event_text.is_some() || system_has_text { self.last_copied_image = None; diff --git a/crates/ui-egui/src/zoom_snap.rs b/crates/ui-egui/src/zoom_snap.rs index eb2cc86727..d74627e877 100644 --- a/crates/ui-egui/src/zoom_snap.rs +++ b/crates/ui-egui/src/zoom_snap.rs @@ -138,10 +138,15 @@ impl PdfCraftApp { return Err(e); } let (w, h) = (out.width, out.height); - #[cfg(not(target_arch = "wasm32"))] - if self.system_clipboard { - let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; - arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + if let Some(mock) = &mut self.clipboard_override { + mock.image = Some((w as usize, h as usize, out.rgba.to_vec())); + mock.text = None; + } else { + #[cfg(not(target_arch = "wasm32"))] + if self.system_clipboard { + let img = arboard::ImageData { width: w as usize, height: h as usize, bytes: std::borrow::Cow::Borrowed(&out.rgba) }; + arboard::Clipboard::new().and_then(|mut c| c.set_image(img)).map_err(|e| e.to_string())?; + } } self.last_snapshot = Some((w, h, out.rgba)); Ok((w, h)) diff --git a/crates/ui-egui/tests/editing.rs b/crates/ui-egui/tests/editing.rs index a9c3dca38a..02e40bdad3 100644 --- a/crates/ui-egui/tests/editing.rs +++ b/crates/ui-egui/tests/editing.rs @@ -44,6 +44,7 @@ fn harness(pages: usize, setup: impl FnOnce(&mut PdfCraftApp) + 'static) -> Harn let mut h = builder.build_eframe(move |_cc| { let mut app = PdfCraftApp::new(); app.set_option("language", "en").unwrap(); + app.clipboard_override = Some(pdfcraft_ui_egui::MockClipboard::default()); app.open_bytes("doc.pdf", None, fixture(pages)).expect("fixture opens"); setup(&mut app); app @@ -4978,11 +4979,13 @@ fn stale_clipboard_plain_text_does_not_paste_image() { h.state_mut().last_copied_image = Some((10, 10, vec![0; 400])); h.state_mut().last_copied_file = Some(("cached.png".to_string(), vec![1, 2, 3])); h.state_mut().system_clipboard = true; + h.state_mut().clipboard_override = None; - // Set actual system clipboard to plain text + // Set actual system clipboard to plain text, asserting success explicitly #[cfg(not(target_arch = "wasm32"))] - if let Ok(mut cb) = arboard::Clipboard::new() { - cb.set_text("just plain text").ok(); + { + let mut cb = arboard::Clipboard::new().expect("acquire OS clipboard"); + cb.set_text("just plain text").expect("set OS clipboard text"); } // A paste event containing plain text clears internal image cache and does not paste image @@ -4999,6 +5002,23 @@ fn stale_clipboard_plain_text_does_not_paste_image() { assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page"); } +#[test] +fn deterministic_mock_clipboard_plain_text_does_not_paste_image() { + let mut h = harness(1, |_| {}); + h.run_steps(4); + h.state_mut().last_copied_image = Some((10, 10, vec![0; 400])); + h.state_mut().last_copied_file = Some(("cached.png".to_string(), vec![1, 2, 3])); + + // Inject plain text into the mock clipboard + h.state_mut().set_mock_clipboard_text("just plain text"); + + assert!(!h.state().can_paste_image(), "cannot paste image when mock clipboard has text"); + assert!(!h.state_mut().paste_image(0, None), "must not paste stale cached image"); + + let doc = h.state().session.get(h.state().views[0].id).unwrap(); + assert_eq!(doc.page_images(0).len(), 0, "no image pasted on the page"); +} + #[test] fn rotated_image_selection_and_crop() { let mut png = Vec::new();