From 9daafb9a40e71f1681c48c441bff46e59cabd804 Mon Sep 17 00:00:00 2001 From: francois-unity <105223066+francois-unity@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:28:54 -0400 Subject: [PATCH] Let TestAppContext render with a real text system, renderer and scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestAppContext::build hardcodes its platform: the stub TestTextSystem, which paints no glyphs, no headless renderer, an empty asset registry, and windows whose scale factor is the constant 2. A test can open a window and read its scene, but not render it to pixels the way an app draws it. HeadlessAppContext can, but it isn’t a TestAppContext, so code written against TestAppContext and VisualTestContext can’t use it. TestAppContext::with_platform takes a text system, an asset source, a headless renderer factory and a scale factor. TestPlatform keeps the scale factor and hands it to each TestWindow, which reports it from scale_factor(), so Window::render_to_image returns an image of the window’s size times that factor. build, and with it #[gpui::test], keeps the stub text system, no renderer, no assets and a scale of 2. gpui_platform::current_headless_renderer now returns WGPU’s headless renderer on Linux, FreeBSD and Windows under test-support, through an optional gpui_wgpu dependency that Linux already builds for its windows. Under bench-support alone it still returns None there. Unit tests drive the new constructor with a recording renderer. A rendering test in gpui_platform renders IBM Plex through Parley with the platform’s headless renderer at two scales, and CI runs it on all three platforms. --- .github/workflows/ci.yml | 6 + Cargo.lock | 1 + crates/gpui/src/app/bench_context.rs | 7 +- crates/gpui/src/app/test_context.rs | 249 ++++++++++++++++-- crates/gpui/src/platform/test/platform.rs | 25 ++ crates/gpui/src/platform/test/window.rs | 5 +- crates/gpui_platform/Cargo.toml | 12 + crates/gpui_platform/src/gpui_platform.rs | 22 +- .../tests/test_app_context_render.rs | 85 ++++++ 9 files changed, 391 insertions(+), 21 deletions(-) create mode 100644 crates/gpui_platform/tests/test_app_context_render.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6674bfec6d..868b62a4b5f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,8 @@ jobs: - run: just build - run: just test - run: just test-doc + - name: Test TestAppContext headless rendering + run: GPUI_RUN_RENDERING_TESTS=1 cargo test -p gpui_ce_platform --features test-support --test test_app_context_render # macOS build + test test-mac: @@ -201,6 +203,8 @@ jobs: - run: just test-doc - name: Test Parley rendering invariants run: GPUI_RUN_RENDERING_TESTS=1 cargo test -p gpui_ce_platform --test parley_render + - name: Test TestAppContext headless rendering + run: GPUI_RUN_RENDERING_TESTS=1 cargo test -p gpui_ce_platform --features test-support --test test_app_context_render # Windows build + test test-windows: @@ -218,6 +222,8 @@ jobs: - run: just test-doc - name: Test Parley rendering invariants run: $env:GPUI_RUN_RENDERING_TESTS=1; cargo test -p gpui_ce_platform --test parley_render + - name: Test TestAppContext headless rendering + run: $env:GPUI_RUN_RENDERING_TESTS=1; cargo test -p gpui_ce_platform --features test-support --test test_app_context_render # Examples check-examples: diff --git a/Cargo.lock b/Cargo.lock index 1577905af60..ede0e324f5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2708,6 +2708,7 @@ dependencies = [ "gpui_ce_apple", "gpui_ce_linux", "gpui_ce_macos", + "gpui_ce_parley", "gpui_ce_web", "gpui_ce_wgpu", "gpui_ce_windows", diff --git a/crates/gpui/src/app/bench_context.rs b/crates/gpui/src/app/bench_context.rs index 2f892d869ea..cce17b1466b 100644 --- a/crates/gpui/src/app/bench_context.rs +++ b/crates/gpui/src/app/bench_context.rs @@ -37,9 +37,10 @@ use crate::{ /// `gpui_platform::current_headless_renderer`. When present, scenes drawn by /// benchmarks are rasterized through the real sprite atlas and submitted to /// the GPU on present, so quad/sprite regressions show up in measurements. -/// When `None`, presenting discards the scene. Currently only macOS provides -/// a headless renderer (Metal), so GPU submission is excluded from benchmark -/// measurements on other platforms. +/// When `None`, presenting discards the scene. Under `bench-support` alone only +/// macOS provides a headless renderer (Metal), so GPU submission is excluded +/// from benchmark measurements on other platforms; `gpui_platform`'s +/// `test-support` adds WGPU's on Linux, FreeBSD and Windows. pub fn bench_platform( headless_renderer_factory: Option Option>>>, text_system: Arc, diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 98fca8d3952..4df6c0125a9 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -1,12 +1,13 @@ use crate::{ - Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetRegistry, AsyncApp, - AvailableSpace, BackgroundExecutor, BorrowAppContext, Bounds, Capslock, ClipboardItem, - DrawPhase, Drawable, Element, Empty, EntityId, EventEmitter, ForegroundExecutor, Global, - InputEvent, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, SharedString, Size, - SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, - TestScreenCaptureSource, TestWindow, TextSystem, VisualContext, Window, WindowBounds, - WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope, + Action, AnyView, AnyWindowHandle, App, AppCell, AppContext, AssetRegistry, AssetSource, + AsyncApp, AvailableSpace, BackgroundExecutor, BorrowAppContext, Bounds, Capslock, + ClipboardItem, DEFAULT_TEST_SCALE_FACTOR, DrawPhase, Drawable, Element, Empty, EntityId, + EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Modifiers, + ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, + Platform, PlatformHeadlessRenderer, PlatformTextSystem, Point, Render, Result, SharedString, + Size, SystemNotification, SystemNotificationResponse, Task, TestDispatcher, TestPlatform, + TestScreenCaptureSource, TestTextSystem, TestWindow, TextSystem, VisualContext, Window, + WindowBounds, WindowHandle, WindowOptions, app::GpuiMode, window::ElementArenaScope, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt, channel::oneshot}; @@ -155,19 +156,86 @@ impl AppContext for TestAppContext { impl TestAppContext { /// Creates a new `TestAppContext`. Usually you can rely on `#[gpui::test]` to do this for you. + /// + /// Its platform shapes text with a stub text system that paints no glyphs, has no + /// renderer, loads no assets, and gives its windows a scale factor of 2. pub fn build(dispatcher: TestDispatcher, fn_name: Option<&'static str>) -> Self { + Self::build_with( + dispatcher, + fn_name, + Arc::new(TestTextSystem), + AssetRegistry::default(), + None, + DEFAULT_TEST_SCALE_FACTOR, + ) + } + + /// Creates a `TestAppContext` whose windows can be rendered to pixels. + /// + /// Takes the text system that shapes and rasterizes text, the asset source behind + /// `svg()` and `img()`, a factory for each window's headless renderer (returning + /// `None` leaves that window without one, as [`Self::build`] does), and the scale + /// factor its windows report. With a renderer, `Window::render_to_image` returns + /// an image of the window's size times that scale factor. + /// + /// Unlike a context from `#[gpui::test]`, nothing quits this one for you: call + /// [`Self::quit`] when the test ends. [`Self::new_app`] still builds the default + /// platform. + /// + /// ```ignore + /// let mut cx = TestAppContext::with_platform( + /// TestDispatcher::new(0), + /// None, + /// Arc::new(gpui_parley::ParleyTextSystem::new(gpui_parley::SystemFonts::Skip)), + /// Arc::new(Assets), + /// gpui_platform::current_headless_renderer, + /// 1.0, + /// ); + /// ``` + pub fn with_platform( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + platform_text_system: Arc, + asset_source: Arc, + renderer_factory: impl Fn() -> Option> + 'static, + scale_factor: f32, + ) -> Self { + assert!( + scale_factor.is_finite() && scale_factor > 0.0, + "a test window's scale factor must be positive, got {scale_factor}" + ); + Self::build_with( + dispatcher, + fn_name, + platform_text_system, + AssetRegistry::from(asset_source), + Some(Box::new(renderer_factory)), + scale_factor, + ) + } + + fn build_with( + dispatcher: TestDispatcher, + fn_name: Option<&'static str>, + platform_text_system: Arc, + asset_registry: AssetRegistry, + renderer_factory: Option Option>>>, + scale_factor: f32, + ) -> Self { let arc_dispatcher = Arc::new(dispatcher.clone()); let background_executor = BackgroundExecutor::new(arc_dispatcher.clone()); let foreground_executor = ForegroundExecutor::new(arc_dispatcher); - let platform = TestPlatform::new(background_executor.clone(), foreground_executor.clone()); + let platform = TestPlatform::with_scale_factor( + background_executor.clone(), + foreground_executor.clone(), + platform_text_system, + renderer_factory, + scale_factor, + ); let http_client = crate::http_client::FakeHttpClient::with_404_response(); let text_system = Arc::new(TextSystem::new(platform.text_system())); - let app = App::new_app( - platform.clone(), - AssetRegistry::default().into(), - http_client, - ); + let app = App::new_app(platform.clone(), asset_registry.into(), http_client); app.borrow_mut().mode = GpuiMode::test(); Self { @@ -1194,12 +1262,161 @@ impl AnyWindowHandle { #[cfg(test)] mod tests { use crate::{ - PathPromptOptions, SystemNotification, SystemNotificationAction, - SystemNotificationResponse, TestAppContext, + AssetSource, Context, DevicePixels, IntoElement, ParentElement as _, PathPromptOptions, + PlatformAtlas, PlatformHeadlessRenderer, Render, Scene, SharedString, Size, Styled as _, + SystemNotification, SystemNotificationAction, SystemNotificationResponse, TestAppContext, + TestAtlas, TestDispatcher, TestTextSystem, VisualTestContext, Window, div, px, size, }; + use image::RgbaImage; + use std::borrow::Cow; use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; + use std::sync::Arc; + + struct Label; + + impl Render for Label { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div().size_full().child("Headless") + } + } + + /// Records the device size of every frame and reads back a blank image of that size. + struct RecordingRenderer { + frame_sizes: Rc>>>, + sprite_atlas: Arc, + } + + impl PlatformHeadlessRenderer for RecordingRenderer { + fn render_scene_to_image( + &mut self, + _scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.frame_sizes.borrow_mut().push(size); + Ok(RgbaImage::new(size.width.0 as u32, size.height.0 as u32)) + } + + fn render_scene(&mut self, _scene: &Scene, size: Size) -> anyhow::Result<()> { + self.frame_sizes.borrow_mut().push(size); + Ok(()) + } + + fn sprite_atlas(&self) -> Arc { + self.sprite_atlas.clone() + } + } + + struct ProbeAssets; + + impl AssetSource for ProbeAssets { + fn load(&self, path: &str) -> anyhow::Result>> { + Ok((path == "icons/probe.svg").then_some(Cow::Borrowed(b"".as_slice()))) + } + + fn list(&self, _path: &str) -> anyhow::Result> { + Ok(vec!["icons/probe.svg".into()]) + } + } + + fn device_size(width: i32, height: i32) -> Size { + Size { + width: DevicePixels(width), + height: DevicePixels(height), + } + } + + #[gpui::test] + fn test_build_keeps_the_stub_platform(cx: &mut TestAppContext) { + let window = cx.open_window(size(px(200.), px(100.)), |_, _| Label); + let cx = &mut VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + + cx.update(|window, cx| { + assert_eq!(window.scale_factor(), 2.0); + let (_, monochrome, subpixel, polychrome) = window.rendered_primitive_counts(); + assert_eq!(monochrome + subpixel + polychrome, 0); + let error = window.render_to_image().unwrap_err(); + assert!( + error.to_string().contains("no HeadlessRenderer configured"), + "{error}" + ); + assert!(cx.assets().load("icons/probe.svg").is_none()); + }); + } + + #[test] + fn test_with_platform_uses_its_assets_renderer_and_scale_factor() { + let frame_sizes = Rc::new(RefCell::new(Vec::new())); + let mut cx = TestAppContext::with_platform( + TestDispatcher::new(0), + None, + Arc::new(TestTextSystem), + Arc::new(ProbeAssets), + { + let frame_sizes = frame_sizes.clone(); + move || { + Some(Box::new(RecordingRenderer { + frame_sizes: frame_sizes.clone(), + sprite_atlas: Arc::new(TestAtlas::new()), + }) as Box) + } + }, + 1.5, + ); + + let probe = cx.update(|cx| { + cx.assets() + .load("icons/probe.svg") + .map(|data| data.into_owned()) + }); + assert_eq!(probe.as_deref(), Some(b"".as_slice())); + + let window = cx.open_window(size(px(200.), px(100.)), |_, _| Label); + let mut visual_cx = VisualTestContext::from_window(window.into(), &cx); + visual_cx.run_until_parked(); + + let image = visual_cx.update(|window, _| { + assert_eq!(window.scale_factor(), 1.5); + assert_eq!(window.viewport_size(), size(px(200.), px(100.))); + window.render_to_image() + }); + assert_eq!(image.unwrap().dimensions(), (300, 150)); + assert!(!frame_sizes.borrow().is_empty()); + assert!( + frame_sizes + .borrow() + .iter() + .all(|frame_size| *frame_size == device_size(300, 150)), + "{:?}", + frame_sizes.borrow() + ); + + visual_cx.simulate_resize(size(px(100.), px(50.))); + visual_cx.run_until_parked(); + let image = visual_cx.update(|window, _| { + assert_eq!(window.scale_factor(), 1.5); + window.render_to_image() + }); + assert_eq!(image.unwrap().dimensions(), (150, 75)); + + drop(visual_cx); + cx.quit(); + } + + #[test] + #[should_panic(expected = "scale factor must be positive")] + fn test_with_platform_rejects_a_zero_scale_factor() { + TestAppContext::with_platform( + TestDispatcher::new(0), + None, + Arc::new(TestTextSystem), + Arc::new(()), + || None, + 0.0, + ); + } #[gpui::test] async fn test_system_notifications_require_identity_and_replace_matching_tags( diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 82e6d0df79f..d8895752610 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -42,9 +42,13 @@ pub(crate) struct TestPlatform { pub expect_restart: RefCell, Vec)>>>, headless_renderer_factory: Option Option>>>, + scale_factor: f32, weak: Weak, } +/// The scale factor a test window reports unless its platform was given another. +pub(crate) const DEFAULT_TEST_SCALE_FACTOR: f32 = 2.0; + #[derive(Clone)] /// A fake screen capture source, used for testing. pub struct TestScreenCaptureSource {} @@ -140,6 +144,25 @@ impl TestPlatform { headless_renderer_factory: Option< Box Option>>, >, + ) -> Rc { + Self::with_scale_factor( + executor, + foreground_executor, + text_system, + headless_renderer_factory, + DEFAULT_TEST_SCALE_FACTOR, + ) + } + + /// Like [`Self::with_platform`], with the scale factor its windows report. + pub(crate) fn with_scale_factor( + executor: BackgroundExecutor, + foreground_executor: ForegroundExecutor, + text_system: Arc, + headless_renderer_factory: Option< + Box Option>>, + >, + scale_factor: f32, ) -> Rc { Rc::new_cyclic(|weak| TestPlatform { background_executor: executor, @@ -161,6 +184,7 @@ impl TestPlatform { system_notifications: Default::default(), text_system, headless_renderer_factory, + scale_factor, }) } @@ -450,6 +474,7 @@ impl Platform for TestPlatform { self.weak.clone(), self.active_display.clone(), renderer, + self.scale_factor, ); Ok(Box::new(window)) } diff --git a/crates/gpui/src/platform/test/window.rs b/crates/gpui/src/platform/test/window.rs index 0d0c2eaa16c..a11c98c8bf6 100644 --- a/crates/gpui/src/platform/test/window.rs +++ b/crates/gpui/src/platform/test/window.rs @@ -30,6 +30,7 @@ pub(crate) struct TestWindowState { // TODO: Replace with `Rc` sprite_atlas: Arc, renderer: Option>, + scale_factor: f32, pub(crate) should_close_handler: Option bool>>, hit_test_window_control_callback: Option Option>>, input_callback: Option DispatchEventResult>>, @@ -80,6 +81,7 @@ impl TestWindow { platform: Weak, display: Rc, renderer: Option>, + scale_factor: f32, ) -> Self { let sprite_atlas: Arc = match &renderer { Some(r) => r.sprite_atlas(), @@ -92,6 +94,7 @@ impl TestWindow { handle, sprite_atlas, renderer, + scale_factor, title: Default::default(), edited: false, document_path: None, @@ -248,7 +251,7 @@ impl PlatformWindow for TestWindow { } fn scale_factor(&self) -> f32 { - 2.0 + self.0.lock().scale_factor } fn appearance(&self) -> WindowAppearance { diff --git a/crates/gpui_platform/Cargo.toml b/crates/gpui_platform/Cargo.toml index bc5c3ef84d3..38305b4f94a 100644 --- a/crates/gpui_platform/Cargo.toml +++ b/crates/gpui_platform/Cargo.toml @@ -25,6 +25,8 @@ test-support = [ "gpui_apple/test-support", "gpui_macos/test-support", "gpui_windows/test-support", + "dep:gpui_wgpu", + "gpui_wgpu?/test-support", ] render-to-image = [ "gpui/render-to-image", @@ -48,6 +50,7 @@ gpui = { workspace = true, default-features = false } [dev-dependencies] gpui_fonts = { path = "../gpui_ce_fonts", version = "0.2.2", package = "gpui-ce-fonts" } +gpui_parley.workspace = true [target.'cfg(target_os = "macos")'.dependencies] gpui_apple.workspace = true @@ -62,6 +65,10 @@ gpui_macos = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } gpui_wgpu = { workspace = true, features = ["test-support"] } +# `current_headless_renderer` under `test-support`. Linux already builds it for its windows. +[target.'cfg(any(target_os = "linux", target_os = "freebsd", target_os = "windows"))'.dependencies] +gpui_wgpu = { workspace = true, optional = true } + [target.'cfg(target_os = "windows")'.dependencies] gpui_windows.workspace = true gpui = { workspace = true, features = ["windows-manifest"] } @@ -77,3 +84,8 @@ console_error_panic_hook.workspace = true name = "parley_render" path = "tests/parley_render.rs" harness = false + +[[test]] +name = "test_app_context_render" +path = "tests/test_app_context_render.rs" +required-features = ["test-support"] diff --git a/crates/gpui_platform/src/gpui_platform.rs b/crates/gpui_platform/src/gpui_platform.rs index f0e2fa40bee..5cd971426b3 100644 --- a/crates/gpui_platform/src/gpui_platform.rs +++ b/crates/gpui_platform/src/gpui_platform.rs @@ -81,6 +81,10 @@ pub fn current_platform(headless: bool) -> Rc { } /// Returns a new [`HeadlessRenderer`] for the current platform, if available. +/// +/// macOS renders with Metal. Linux, FreeBSD and Windows render with WGPU under the +/// `test-support` feature, and panic when no GPU adapter is found; a software one, +/// such as Mesa's lavapipe or WARP, is enough. #[cfg(any(feature = "bench-support", feature = "test-support"))] pub fn current_headless_renderer() -> Option> { #[cfg(target_os = "macos")] @@ -91,7 +95,23 @@ pub fn current_headless_renderer() -> Option) + } + + #[cfg(not(any( + target_os = "macos", + all( + feature = "test-support", + any(target_os = "linux", target_os = "freebsd", target_os = "windows") + ) + )))] { None } diff --git a/crates/gpui_platform/tests/test_app_context_render.rs b/crates/gpui_platform/tests/test_app_context_render.rs new file mode 100644 index 00000000000..02e8b13c872 --- /dev/null +++ b/crates/gpui_platform/tests/test_app_context_render.rs @@ -0,0 +1,85 @@ +//! Renders a `TestAppContext::with_platform` window with this platform's headless +//! renderer and a real text system. It needs a GPU adapter, so it runs only when +//! `GPUI_RUN_RENDERING_TESTS` is set. + +use gpui::{ + Context, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext, TestDispatcher, + VisualTestContext, Window, div, px, rgb, size, white, +}; +use gpui_fonts::IBM_PLEX; +use gpui_parley::{ParleyTextSystem, SystemFonts}; +use std::{borrow::Cow, sync::Arc}; + +const BACKGROUND: [u8; 3] = [0x10, 0x14, 0x18]; + +struct Fixture; + +impl Render for Fixture { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .size_full() + .bg(rgb(0x101418)) + .text_color(white()) + .font_family("IBM Plex Sans") + .text_size(px(24.0)) + .p(px(16.0)) + .child("Headless rendering") + } +} + +#[test] +fn test_app_context_renders_real_text_at_the_configured_scale() { + if std::env::var_os("GPUI_RUN_RENDERING_TESTS").is_none() { + return; + } + + for scale_factor in [1.0, 1.5] { + let mut cx = TestAppContext::with_platform( + TestDispatcher::new(0), + None, + Arc::new(ParleyTextSystem::new_with_system_font( + SystemFonts::Skip, + "IBM Plex Sans", + )), + Arc::new(()), + gpui_ce_platform::current_headless_renderer, + scale_factor, + ); + cx.update(|cx| { + cx.text_system() + .add_fonts(vec![Cow::Borrowed(IBM_PLEX.data)]) + }) + .expect("failed to load the IBM Plex fixture"); + + let window = cx.open_window(size(px(320.0), px(80.0)), |_, _| Fixture); + let mut visual_cx = VisualTestContext::from_window(window.into(), &cx); + visual_cx.run_until_parked(); + + let (glyph_sprites, image) = visual_cx.update(|window, _| { + let (_, monochrome, subpixel, _) = window.rendered_primitive_counts(); + (monochrome + subpixel, window.render_to_image()) + }); + let image = image.expect("failed to render the window to an image"); + + assert!(glyph_sprites > 0, "the scene held no glyph sprites"); + assert_eq!( + image.dimensions(), + ((320.0 * scale_factor) as u32, (80.0 * scale_factor) as u32), + "at scale {scale_factor}" + ); + + let corner = image.get_pixel(0, 0).0; + assert!( + corner + .iter() + .zip(BACKGROUND) + .all(|(channel, expected)| channel.abs_diff(expected) <= 2), + "the corner pixel was {corner:?}, expected the background {BACKGROUND:?}" + ); + let ink = image.pixels().filter(|pixel| pixel.0 != corner).count(); + assert!(ink > 200, "only {ink} pixels differ from the background"); + + drop(visual_cx); + cx.quit(); + } +}