diff --git a/.cargo/config.toml b/.cargo/config.toml index 15e2276c..35049cbc 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -1,7 +1,2 @@ [alias] xtask = "run --package xtask --" -# The UI tests read pixels back from one offscreen GPU renderer each. Run in parallel, the -# readbacks come back blank (see tests/ui.rs, the highlight and ink tests), so the test threads -# are one. Set RUST_TEST_THREADS yourself to override it. -[env] -RUST_TEST_THREADS = "1" diff --git a/.github/workflows/website-pipeline.yml b/.github/workflows/website-pipeline.yml index 66290263..00caf091 100644 --- a/.github/workflows/website-pipeline.yml +++ b/.github/workflows/website-pipeline.yml @@ -60,6 +60,14 @@ jobs: - name: Install the wasm32 target run: rustup target add wasm32-unknown-unknown + # The demo's dependencies are the same from one build to the next, so they come from the + # cache rather than being compiled again. Only main saves it (the nightly release builds the + # site from main every night); pull requests restore it, as in ci.yml. + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: website-wasm + save-if: ${{ github.ref == 'refs/heads/main' }} + - name: Install trunk run: | set -eu diff --git a/crates/ui-egui/tests/icons_visual.rs b/crates/ui-egui/tests/icons_visual.rs index fb6a0404..8bfc37ab 100644 --- a/crates/ui-egui/tests/icons_visual.rs +++ b/crates/ui-egui/tests/icons_visual.rs @@ -581,6 +581,10 @@ fn render(scale: f32, bg: Color32, draw: impl Fn(&Painter) + 'static) -> RgbaIma draw(ui.painter()); }); h.run(); + // One readback at a time on the shared device, as in `support/gpu.rs`: two in flight at once + // can come back blank. Everything before this line still runs on every test thread. + static READBACK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _turn = READBACK.lock().unwrap_or_else(std::sync::PoisonError::into_inner); h.render().expect("an offscreen renderer is required for the visual suite") } diff --git a/crates/ui-egui/tests/support/gpu.rs b/crates/ui-egui/tests/support/gpu.rs index bfdc637d..9bb91ee3 100644 --- a/crates/ui-egui/tests/support/gpu.rs +++ b/crates/ui-egui/tests/support/gpu.rs @@ -6,10 +6,16 @@ //! on Linux. The same fix the icon tests use (`icons_visual.rs`) applies here: the adapter, //! device and queue are shared, and each window still gets its own `Renderer` with its own //! textures and pipelines, so nothing drawn in one test can leak into another. +//! +//! Reading a picture back from that shared device is done one window at a time: two readbacks in +//! flight on the same queue at once came back blank (the highlight and ink tests). Take a picture +//! with [`render`], never `Harness::render` directly. Only the readback waits its turn; building +//! windows, clicking and typing still run on every test thread, which is most of the suite's time. +use egui_kittest::Harness; use egui_kittest::wgpu::{WgpuTestRenderer, create_render_state, default_wgpu_setup}; use egui_wgpu::{RenderState, Renderer, RendererOptions}; -use std::sync::{Arc, OnceLock}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; fn shared() -> &'static RenderState { static STATE: OnceLock = OnceLock::new(); @@ -23,3 +29,11 @@ pub fn renderer() -> WgpuTestRenderer { let renderer = Renderer::new(&base.device, base.target_format, RendererOptions::PREDICTABLE); WgpuTestRenderer::from_render_state(RenderState { renderer: Arc::new(egui::mutex::RwLock::new(renderer)), ..base.clone() }) } + +/// The window's picture, read back while no other test is reading one from the shared device. +pub fn render(h: &mut Harness<'_, State>) -> Result { + static READBACK: Mutex<()> = Mutex::new(()); + // A test that panicked mid-readback leaves the device as usable as before; the next one goes on. + let _turn = READBACK.lock().unwrap_or_else(PoisonError::into_inner); + h.render() +} diff --git a/crates/ui-egui/tests/ui.rs b/crates/ui-egui/tests/ui.rs index 36dc2fe5..be9eb633 100644 --- a/crates/ui-egui/tests/ui.rs +++ b/crates/ui-egui/tests/ui.rs @@ -187,7 +187,7 @@ fn review(h: &mut Harness<'_, NoteCraftApp>, name: &str) { if let Some(dir) = std::env::var_os("NOTECRAFT_UI_VISUAL_DIR") { let dir = PathBuf::from(dir); let _ = std::fs::create_dir_all(&dir); - if let Ok(img) = h.render() { + if let Ok(img) = support::gpu::render(h) { let _ = img.save(dir.join(format!("{name}.png"))); } } @@ -341,7 +341,7 @@ fn header_language_picker_labels_render_light_text_on_the_purple_band() { h.get_by_label("Interface language: English").click(); settle(&mut h); let label_rect = h.get_by_label("Fran\u{00e7}ais").rect(); - let image = h.render().expect("render open language picker"); + let image = support::gpu::render(&mut h).expect("render open language picker"); let scale = image.width() as f32 / 1200.0; let min_x = (label_rect.min.x * scale).floor().max(0.0) as u32; let max_x = (label_rect.max.x * scale).ceil().min(image.width() as f32) as u32; @@ -490,7 +490,7 @@ fn middle_drag_pans_the_canvas_for_html_and_cmgfm_pages() { assert_eq!(drawn.pointer("/ink/strokes").and_then(Value::as_u64), Some(1), "open canvas accepts ink for {label}: {drawn}"); let ink_file = path.join("ink.svg"); let svg = std::fs::read_to_string(&ink_file).unwrap_or_else(|error| panic!("saved canvas ink: {error}; state={drawn}")); - let ink_image = h.render().expect("render outside-page stroke"); + let ink_image = support::gpu::render(&mut h).expect("render outside-page stroke"); review(&mut h, if markdown { "canvas_open_ink_cmgfm" } else { "canvas_open_ink_html" }); let red_canvas_pixels = ink_image .pixels() @@ -570,7 +570,7 @@ fn drawing_over_text_keeps_ink_above_the_page_without_editing_words() { ); assert_eq!(state.pointer("/page/caret").cloned().unwrap_or(Value::Null), caret_before, "drawing over words leaves the text caret alone: {state}"); - let image = h.render().expect("render ink over words"); + let image = support::gpu::render(&mut h).expect("render ink over words"); let region = egui::Rect::from_min_max(egui::pos2(x as f32 - 4.0, y as f32 - 4.0), egui::pos2(x as f32 + 42.0, y as f32 + 12.0)); let red_pixels = image .pixels() @@ -619,14 +619,14 @@ fn pen_palette_owns_pointer_input_over_the_page_canvas() { let more_colours = h.get_by_label("More Colours").rect(); ask(&mut h, "ui.move", json!({"x": more_colours.center().x, "y": more_colours.center().y})).expect("hover the popup button"); settle(&mut h); - let before = h.render().expect("render the open palette before drawing"); + let before = support::gpu::render(&mut h).expect("render the open palette before drawing"); let start = egui::pos2(page.max.x - 40.0, more_colours.center().y); assert!(page.contains(start) && !more_colours.contains(start), "the stroke begins on page canvas outside the palette"); ask(&mut h, "ui.press", json!({"x": start.x, "y": start.y})).expect("start a page stroke"); pump(&mut h); ask(&mut h, "ui.move", json!({"x": more_colours.center().x, "y": more_colours.center().y})).expect("carry the stroke across the palette"); pump(&mut h); - let during = h.render().expect("render the stroke beneath the open palette"); + let during = support::gpu::render(&mut h).expect("render the stroke beneath the open palette"); let (sx, sy) = (before.width() as f32 / 1200.0, before.height() as f32 / 760.0); let (x0, y0, x1, y1) = ( (more_colours.min.x * sx).floor().max(0.0) as u32, @@ -1342,7 +1342,7 @@ fn the_plus_between_two_pages_is_round() { for _ in 0..4 { pump(&mut h); } - let shot = h.render().expect("a picture"); + let shot = support::gpu::render(&mut h).expect("a picture"); // The harness is 1200 points wide; the picture may be rendered larger than that. let scale = f64::from(shot.width()) / 1200.0; @@ -1958,7 +1958,7 @@ fn display_math_paints_ink_inside_its_box() { notecraft_ui_egui::mathview::display(ui, "x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}", 28.0, egui::Color32::BLACK); }); h.run(); - let img = h.render().expect("render"); + let img = support::gpu::render(&mut h).expect("render"); let dark: Vec<(u32, u32)> = img.enumerate_pixels().filter(|(_, _, p)| p.0[0] < 100 && p.0[1] < 100 && p.0[2] < 100).map(|(x, y, _)| (x, y)).collect(); assert!(dark.len() > 300, "the formula should leave ink, got {} dark pixels", dark.len()); @@ -2035,7 +2035,7 @@ fn a_swatch_overrides_the_system_mode() { assert_eq!(h.state().theme(), Theme { mode: other, accent: Accent::Default }); assert_eq!(h.state().mode_pref(), if other == Mode::Dark { ModePref::Dark } else { ModePref::Light }, "the swatch stops following the system"); // And the window really is painted in that mode. - let img = h.render().expect("render"); + let img = support::gpu::render(&mut h).expect("render"); let total = img.pixels().count(); let matching = img .pixels() @@ -2180,7 +2180,7 @@ fn page_ink_is_visible_across_container_borders() { let ink = ask(&mut h, "ui.inspect", json!({})).expect("inspect")["ink"].clone(); assert_eq!(ink["strokes"].as_u64(), Some(1), "the pen made one page stroke: {ink}"); - let image = h.render().expect("render the page with the stroke"); + let image = support::gpu::render(&mut h).expect("render the page with the stroke"); review(&mut h, "ink_across_boxes"); let scale = image.width() as f64 / 1200.0; let visible_red_near = |x: f64| { @@ -2257,7 +2257,7 @@ fn page_ink_crosses_a_diagram_container_edge() { settle(&mut h); let ink = ask(&mut h, "ui.inspect", json!({})).expect("inspect strokes")["ink"].clone(); assert_eq!(ink["strokes"].as_u64(), Some(2), "both strokes remain on the page: {ink}"); - let image = h.render().expect("render diagram and ink"); + let image = support::gpu::render(&mut h).expect("render diagram and ink"); review(&mut h, "ink_across_diagram_container"); let scale = image.width() as f64 / 1200.0; let visible_red = |x: f64, at_y: f64| { @@ -5468,7 +5468,7 @@ fn all_fourteen_themes_render_with_their_own_colours() { app.open_path(&s.md_page.to_string_lossy()); let mut h = harness(app, theme); settle(&mut h); - let img = h.render().expect("render"); + let img = support::gpu::render(&mut h).expect("render"); let tokens = theme.tokens(); let near = |p: &image::Rgba, c: egui::Color32| { (i32::from(p.0[0]) - i32::from(c.r())).abs() <= 3 @@ -6779,7 +6779,7 @@ fn a_drag_across_an_arabic_line_selects_it_and_the_words_stay_readable() { let (at, anchor) = (caret.get("at").and_then(Value::as_u64).unwrap_or(0), caret.get("anchor").and_then(Value::as_u64).unwrap_or(0)); assert_ne!(at, anchor, "and the drag selected something: at {at}, anchor {anchor}"); - let selected = h.render().expect("a picture of the window"); + let selected = support::gpu::render(&mut h).expect("a picture of the window"); review(&mut h, "rtl-drag-selection"); // The same page with nothing selected, to compare against. @@ -6787,7 +6787,7 @@ fn a_drag_across_an_arabic_line_selects_it_and_the_words_stay_readable() { settle(&mut h); let caret = ask(&mut h, "ui.inspect", json!({})).expect("inspect").pointer("/page/caret").cloned().unwrap_or(Value::Null); assert_eq!(caret.get("at").and_then(Value::as_u64), caret.get("anchor").and_then(Value::as_u64), "an arrow without Shift drops the selection"); - let plain = h.render().expect("a picture of the window"); + let plain = support::gpu::render(&mut h).expect("a picture of the window"); // The band the drag covered, in the rendered picture's own pixels. let scale = selected.width() as f32 / 1200.0; @@ -6851,7 +6851,7 @@ fn a_drag_can_select_across_paragraphs() { let open = across(&mut h); assert_eq!(open.pointer("/from/0").and_then(Value::as_u64), Some(0), "it begins in the paragraph the press fell on: {open}"); assert_eq!(open.pointer("/to/0").and_then(Value::as_u64), Some(1), "and reaches the one the drag ended in: {open}"); - let selected = h.render().expect("picture"); + let selected = support::gpu::render(&mut h).expect("picture"); review(&mut h, "cross-block"); // Both paragraphs are drawn with a band behind them. Compared against the same page @@ -6859,7 +6859,7 @@ fn a_drag_can_select_across_paragraphs() { ask(&mut h, "ui.click", json!({"x": 600.0, "y": first.min.y - 40.0})).expect("let go"); settle(&mut h); assert_eq!(across(&mut h), Value::Null, "a press somewhere else lets the selection go"); - let plain = h.render().expect("picture"); + let plain = support::gpu::render(&mut h).expect("picture"); let scale = selected.width() as f32 / 1200.0; let band = |rect: egui::Rect| -> u32 { let (x0, x1) = ((rect.min.x * scale) as u32, (rect.max.x * scale) as u32); @@ -6922,7 +6922,7 @@ fn a_drag_highlights_the_words_past_the_marks_in_a_paragraph() { let open = ask(&mut h, "ui.inspect", json!({})).expect("inspect").pointer("/page/across").cloned().unwrap_or(Value::Null); assert_eq!(open.pointer("/from/0").and_then(Value::as_u64), Some(0), "the drag spans both paragraphs: {open}"); assert_eq!(open.pointer("/to/0").and_then(Value::as_u64), Some(1), "the drag spans both paragraphs: {open}"); - let selected = h.render().expect("picture"); + let selected = support::gpu::render(&mut h).expect("picture"); review(&mut h, "cross-block-marks"); // The band is the selection wash painted behind the words; counting its pixels says