mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-04 01:29:57 +00:00
test: implement deterministic mock clipboard to resolve Windows heap corruption
This commit is contained in:
1 parent
ab6a4109a8
commit
c4e625f0ec
9 files changed
+186
-39
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user