test: implement deterministic mock clipboard to resolve Windows heap corruption

This commit is contained in:
Combiler01 committed 2026-10-17 21:29:55 +05:30
1 parent ab6a4109a8
commit c4e625f0ec
9 files changed
+186 -39

No files matched your search

+2 -2
View File
@@ -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'
+2
View File
@@ -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")]
+38
View File
@@ -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<String>,
pub image: Option<(usize, usize, Vec<u8>)>,
pub files: Option<Vec<PathBuf>>,
}
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<String>) -> 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<u8>) -> 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;
}
}
+52 -19
View File
@@ -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<u8>)>) = (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<u8>)> = if has_system_cb {
match os_image {
+18 -4
View File
@@ -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<Clip> = 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) {
+1 -1
View File
@@ -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")?;
+41 -6
View File
@@ -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<u8>)>,
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<MockClipboard>,
/// Attach file: the file to attach instead of asking (tests, automation).
pub attach_override: Option<(String, Vec<u8>)>,
/// 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<String>) {
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<u8>) {
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;
+9 -4
View File
@@ -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))
+23 -3
View File
@@ -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();