diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index db6e3bec2..6694629f8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -240,9 +240,15 @@ features, alongside source, packaging, retained release-contract, and C# tool checks when .NET is installed. CI runs the equivalent C# command and additionally checks dynamic and static gtk4-layer-shell linkage and uploads its code-health report. -GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston and -`dbus-run-session` required). It creates a private headless display and requires -GTK initialization; an unavailable display fails this check. Successful widget -bodies print `EXECUTED` markers. Ordinary tests without a display report an -optional skip. Neither route proves layer-shell focus or screen capture behavior -on a user's compositor. +GTK widget coverage runs separately with `./tools/test-gtk-widgets.sh` (Weston, +`dbus-run-session`, Python 3, `pkg-config`, Mesa software OpenGL, and Wayland +protocol XML required). It creates a private headless display and requires GTK +initialization; an unavailable display fails this check. The native popup tests +use another private Weston with software OpenGL and a protocol proxy to hold one +popup frame callback while its shared clock paints, then require capture to finish +after the popup's fresh render is acknowledged. They cover a plain `GtkPopover` +and `GtkPopoverMenu`'s empty proof overlay and menu restoration. Successful bodies +print `EXECUTED` markers. Ordinary widget tests without a display report an +optional skip; when a display is available, their GTK assertions run. The native +popup tests require the dedicated GTK gate. Neither route proves layer-shell +focus or screen capture behavior on a user's compositor. diff --git a/src/toolbar_gtk/view/capture_proof.rs b/src/toolbar_gtk/view/capture_proof.rs new file mode 100644 index 000000000..43c793c04 --- /dev/null +++ b/src/toolbar_gtk/view/capture_proof.rs @@ -0,0 +1,96 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gtk4::gdk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +#[derive(Clone, Copy)] +pub(super) struct ProofRender { + pub serial: u8, + pub frame_counter: i64, +} + +pub(super) type ProofObservation = Rc>>; + +mod imp { + use super::*; + + #[derive(Default)] + pub struct CaptureProof { + pub(super) texture: RefCell>, + pub(super) picture: glib::WeakRef, + pub(super) observed: RefCell>, + pub(super) serial: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for CaptureProof { + const NAME: &'static str = "WayscriberCaptureProof"; + type Type = super::CaptureProof; + type Interfaces = (gdk::Paintable,); + } + + impl ObjectImpl for CaptureProof {} + + impl gdk::subclass::prelude::PaintableImpl for CaptureProof { + fn snapshot(&self, snapshot: &gdk::Snapshot, width: f64, height: f64) { + let texture = self.texture.borrow(); + let Some(texture) = texture.as_ref() else { + return; + }; + + texture.snapshot(snapshot, width, height); + + if let Some(picture) = self.picture.upgrade() + && let Some(clock) = picture.frame_clock() + && let Some(observed) = self.observed.borrow().as_ref() + { + let serial = self.serial.get(); + let frame = clock.frame_counter(); + observed.set(Some(ProofRender { + serial, + frame_counter: frame, + })); + } + } + + fn flags(&self) -> gdk::PaintableFlags { + gdk::PaintableFlags::STATIC_CONTENTS | gdk::PaintableFlags::STATIC_SIZE + } + + fn intrinsic_width(&self) -> i32 { + 1 + } + + fn intrinsic_height(&self) -> i32 { + 1 + } + + fn intrinsic_aspect_ratio(&self) -> f64 { + 1.0 + } + } +} + +glib::wrapper! { + pub struct CaptureProof(ObjectSubclass) @implements gdk::Paintable; +} + +impl CaptureProof { + pub(super) fn new( + texture: &gdk::MemoryTexture, + picture: >k4::Picture, + observed: ProofObservation, + serial: u8, + ) -> Self { + let proof: Self = glib::Object::new(); + proof.imp().texture.replace(Some(texture.clone())); + proof.imp().picture.set(Some(picture)); + proof.imp().observed.replace(Some(observed)); + proof.imp().serial.set(serial); + + proof + } +} diff --git a/src/toolbar_gtk/view/capture_suppression.rs b/src/toolbar_gtk/view/capture_suppression.rs index a0cf020d4..104255b63 100644 --- a/src/toolbar_gtk/view/capture_suppression.rs +++ b/src/toolbar_gtk/view/capture_suppression.rs @@ -7,10 +7,7 @@ use std::time::{Duration, Instant}; use gtk4::prelude::*; -use super::{ - CaptureProofTarget, CaptureSurfaceContent, after_next_surface_paint_counter, - widget_native_is_mapped, -}; +use super::{CaptureProofTarget, CaptureSurfaceContent, widget_native_is_mapped}; use crate::toolbar_gtk::css::CAPTURE_TRANSPARENT_CLASS; use native_popovers::NativePopoverCapture; @@ -398,8 +395,6 @@ async fn wait_for_surface_presentation( "GTK capture suppression generation {generation} found no frame clock for mapped {name}" ) })?; - let rendered_counter = Rc::new(Cell::new(None)); - let callback_counter = Rc::clone(&rendered_counter); log::info!( "capture.preflight id={generation} component=gtk surface={name} phase=proof-requested sequence={ordinal}/{target_count} elapsed_ms={}", @@ -411,11 +406,10 @@ async fn wait_for_surface_presentation( // without a new Wayland buffer or presentation event. Give this target a // fresh but still alpha-zero texture before arming its dedicated frame. target.refresh_transparent_proof(); - after_next_surface_paint_counter(widget, move |counter| { - callback_counter.set(counter); - }); + // A shared frame clock can paint the parent while the popup is frozen. + // Bind this proof to the fresh paintable's actual native snapshot. - while rendered_counter.get().is_none() { + while target.content.proof_frame().is_none() { reject_withdrawn_target(generation, target, started)?; if proof_started.elapsed() >= CAPTURE_PAINT_TIMEOUT || Instant::now() >= deadline { return Err(format!( @@ -425,7 +419,7 @@ async fn wait_for_surface_presentation( gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; } - let Some(frame_counter) = rendered_counter.get() else { + let Some(frame_counter) = target.content.proof_frame() else { return Err(format!( "GTK capture suppression generation {generation} lost the dedicated transparent render from {name}" )); @@ -493,6 +487,7 @@ fn reject_withdrawn_target( #[cfg(test)] mod tests { + mod presentation; mod process; use super::*; diff --git a/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs b/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs new file mode 100644 index 000000000..889561824 --- /dev/null +++ b/src/toolbar_gtk/view/capture_suppression/tests/presentation.rs @@ -0,0 +1,280 @@ +use super::*; +use std::future::{Future, poll_fn}; +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::task::Poll; + +const CHILD_ENV: &str = "WAYSCRIBER_GTK_POPUP_PRESENTATION_CHILD"; +const TEST_GENERATION: u64 = 1; +const FIXTURE_WAIT: Duration = Duration::from_secs(2); + +#[test] +fn popup_capture_waits_for_its_own_render_after_a_shared_clock_paint() { + let name = concat!( + module_path!(), + "::popup_capture_waits_for_its_own_render_after_a_shared_clock_paint" + ); + if !run_in_private_display(name) { + return; + } + + gtk4::init().expect("private GTK display"); + let (window, anchor) = popup_parent(); + let content = CaptureSurfaceContent::new(>k4::Label::new(Some("Popup content"))); + let popover = gtk4::Popover::new(); + popover.set_autohide(false); + popover.set_has_arrow(false); + popover.set_child(Some(content.widget())); + popover.set_parent(&anchor); + window.present(); + + gtk4::glib::MainContext::default().block_on(async { + wait_for_native_mapping(window.upcast_ref()).await; + require_gl_renderer(&window); + + popover.popup(); + wait_for_native_mapping(popover.upcast_ref()).await; + wait_for_initial_popup_callback().await; + + control("arm"); + content.set_transparent(true); + let target = CaptureProofTarget::new_withdrawable("canvas", &popover, &content); + assert_popup_proof(&window, target).await; + }); + + content.set_transparent(false); + popover.popdown(); + popover.unparent(); + window.destroy(); + eprintln!("EXECUTED: GTK native popup presentation regression"); +} + +#[test] +fn menu_capture_presents_its_empty_proof_and_restores_the_menu() { + let name = concat!( + module_path!(), + "::menu_capture_presents_its_empty_proof_and_restores_the_menu" + ); + if !run_in_private_display(name) { + return; + } + + gtk4::init().expect("private GTK display"); + let (window, anchor) = popup_parent(); + let model = gtk4::gio::Menu::new(); + model.append(Some("Copy"), Some("clipboard.copy")); + let menu = gtk4::PopoverMenu::from_model(Some(&model)); + menu.set_autohide(false); + menu.set_has_arrow(false); + menu.set_parent(&anchor); + let original_child = menu.child().expect("menu child"); + let original_model = menu.menu_model(); + let capture = TooltipCapture::new(); + window.present(); + + gtk4::glib::MainContext::default().block_on(async { + wait_for_native_mapping(window.upcast_ref()).await; + require_gl_renderer(&window); + + menu.popup(); + wait_for_native_mapping(menu.upcast_ref()).await; + wait_for_initial_popup_callback().await; + + control("arm"); + capture.set_suppressed(true); + capture.install_tree(window.upcast_ref()); + let mut targets = capture.capture_popover_targets(); + assert_eq!(targets.len(), 1, "mapped native menu must be enrolled"); + let target = targets.pop().unwrap(); + assert_eq!(target.content.content_opacity(), None); + assert_eq!(menu.child().as_ref(), Some(&original_child)); + + assert_popup_proof(&window, target).await; + capture.mark_capture_popovers_proven(); + assert!(capture.pending_capture_popover_targets().is_empty()); + }); + + capture.set_suppressed(false); + assert_eq!(menu.menu_model(), original_model); + assert_eq!(menu.child().as_ref(), Some(&original_child)); + menu.popdown(); + menu.unparent(); + window.destroy(); + eprintln!("EXECUTED: GTK native menu presentation assertions"); +} + +fn run_in_private_display(test_name: &str) -> bool { + if std::env::var_os(CHILD_ENV).is_some() { + return true; + } + if std::env::var_os("WAYSCRIBER_REQUIRE_GTK_TESTS").is_none() { + eprintln!("skipping native popup presentation test: run tools/test-gtk-widgets.sh"); + return false; + } + + let test_name = test_name + .strip_prefix(concat!(env!("CARGO_CRATE_NAME"), "::")) + .expect("test name contains the crate prefix"); + let fixture = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tools/test-fixtures/gtk_popup_wayland.py" + ); + let output = std::process::Command::new("python3") + .arg(fixture) + .arg(std::env::current_exe().expect("test binary")) + .arg(test_name) + .env(CHILD_ENV, "1") + .output() + .expect("run private Wayland popup fixture"); + std::io::stdout().write_all(&output.stdout).unwrap(); + std::io::stderr().write_all(&output.stderr).unwrap(); + + assert!( + output.status.success(), + "native popup presentation test failed" + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed; 0 failed;"), + "private fixture must execute exactly one passing test" + ); + + false +} + +fn popup_parent() -> (gtk4::Window, gtk4::Button) { + let window = gtk4::Window::new(); + window.set_default_size(320, 200); + let anchor = gtk4::Button::with_label("Canvas"); + window.set_child(Some(&anchor)); + + (window, anchor) +} + +fn require_gl_renderer(window: >k4::Window) { + assert!( + window + .renderer() + .expect("window renderer") + .is::() + ); +} + +async fn wait_for_native_mapping(widget: >k4::Widget) { + let deadline = Instant::now() + FIXTURE_WAIT; + loop { + if widget_native_is_mapped(widget) && widget.width() > 0 && widget.height() > 0 { + return; + } + + assert!( + Instant::now() < deadline, + "native surface did not map with nonzero bounds" + ); + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +async fn wait_for_initial_popup_callback() { + let deadline = Instant::now() + FIXTURE_WAIT; + while control("status").popup_callbacks_delivered == 0 { + assert!( + Instant::now() < deadline, + "initial popup frame did not complete" + ); + + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +async fn assert_popup_proof(window: >k4::Window, target: CaptureProofTarget) { + let popup = target.widget.clone(); + let clock = popup.frame_clock().expect("popup frame clock"); + assert_eq!(window.frame_clock().as_ref(), Some(&clock)); + wait_for_held_popup_callback().await; + + let held_commits = control("status").popup_commits; + let shared_paint = Rc::new(Cell::new(None)); + let paint_observation = Rc::clone(&shared_paint); + let handler = clock.connect_after_paint(move |clock| { + paint_observation.set(Some(clock.frame_counter())); + }); + let proof = wait_for_presented_transparency(TEST_GENERATION, vec![target]); + let mut proof = std::pin::pin!(proof); + + // Arm the real capture wait before asking the shared clock to paint. + // A separately spawned task could start after that paint and miss the race. + assert!( + poll_fn(|cx| Poll::Ready(proof.as_mut().poll(cx))) + .await + .is_pending() + ); + clock.request_phase(gtk4::gdk::FrameClockPhase::PAINT); + + let deadline = Instant::now() + Duration::from_millis(250); + while shared_paint.get().is_none() { + assert!(Instant::now() < deadline, "shared clock did not paint"); + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } + clock.disconnect(handler); + + assert!( + poll_fn(|cx| Poll::Ready(proof.as_mut().poll(cx))) + .await + .is_pending(), + "a parent paint cannot admit the popup" + ); + let status = control("status"); + assert!(status.held); + assert_eq!(status.popup_commits, held_commits); + + control("release"); + proof.await.expect( + "the fresh popup render must complete capture after the previous callback is released", + ); + + let status = control("status"); + assert!(status.popup_commits > held_commits); + assert!(status.popup_callbacks_delivered >= 2); + assert!(widget_native_is_mapped(&popup)); +} + +async fn wait_for_held_popup_callback() { + let deadline = Instant::now() + FIXTURE_WAIT; + while !control("status").held { + assert!( + Instant::now() < deadline, + "popup never submitted the held callback" + ); + + gtk4::glib::timeout_future(CAPTURE_PAINT_POLL_INTERVAL).await; + } +} + +#[derive(Debug, serde::Deserialize)] +struct ProxyStatus { + held: bool, + popup_commits: u64, + popup_callbacks_delivered: u64, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(untagged)] +enum ProxyResponse { + Status(ProxyStatus), + Error { error: String }, +} + +fn control(command: &str) -> ProxyStatus { + let path = std::env::var_os("WAYSCRIBER_GTK_WAYLAND_CONTROL").expect("private proxy control"); + let mut socket = UnixStream::connect(path).expect("connect proxy control"); + socket.set_read_timeout(Some(FIXTURE_WAIT)).unwrap(); + + writeln!(socket, "{command}").unwrap(); + let mut response = String::new(); + BufReader::new(socket).read_line(&mut response).unwrap(); + + match serde_json::from_str(&response).expect("proxy response") { + ProxyResponse::Status(status) => status, + ProxyResponse::Error { error } => panic!("proxy error: {error}"), + } +} diff --git a/src/toolbar_gtk/view/mod.rs b/src/toolbar_gtk/view/mod.rs index 8eaa1a1d1..169a32262 100644 --- a/src/toolbar_gtk/view/mod.rs +++ b/src/toolbar_gtk/view/mod.rs @@ -1,6 +1,7 @@ //! GTK toolbar windows: owns the top strip, the shared //! stylesheet, and output pinning. +mod capture_proof; mod capture_suppression; mod drag; mod sections; @@ -141,6 +142,7 @@ pub(super) struct CaptureSurfaceContent { root: gtk4::Overlay, proof: gtk4::Picture, proof_serial: std::rc::Rc>, + proof_observation: capture_proof::ProofObservation, } impl CaptureSurfaceContent { @@ -151,7 +153,11 @@ impl CaptureSurfaceContent { let bytes = gtk4::glib::Bytes::from_static(&[0xff, 0x00, 0xff, 0x00]); let texture = gtk4::gdk::MemoryTexture::new(1, 1, gtk4::gdk::MemoryFormat::R8g8b8a8, &bytes, 4); - let proof = gtk4::Picture::for_paintable(&texture); + let proof_observation = std::rc::Rc::new(std::cell::Cell::new(None)); + let proof = gtk4::Picture::new(); + let paintable = + capture_proof::CaptureProof::new(&texture, &proof, proof_observation.clone(), 0); + proof.set_paintable(Some(&paintable)); proof.set_content_fit(gtk4::ContentFit::Fill); proof.set_halign(gtk4::Align::Fill); proof.set_valign(gtk4::Align::Fill); @@ -162,12 +168,16 @@ impl CaptureSurfaceContent { let root = gtk4::Overlay::new(); root.add_css_class(CAPTURE_SURFACE_CONTENT_CLASS); + // GTK-owned menus have no ordinary child in this wrapper. Keep their + // proof area nonzero so GTK actually snapshots the alpha-zero texture. + root.set_size_request(1, 1); root.add_overlay(&proof); Self { root, proof, proof_serial: std::rc::Rc::new(std::cell::Cell::new(0)), + proof_observation, } } @@ -225,10 +235,24 @@ impl CaptureSurfaceContent { let bytes = gtk4::glib::Bytes::from_owned(bytes); let texture = gtk4::gdk::MemoryTexture::new(1, 1, gtk4::gdk::MemoryFormat::R8g8b8a8, &bytes, 4); - self.proof.set_paintable(Some(&texture)); + self.proof_observation.set(None); + let paintable = capture_proof::CaptureProof::new( + &texture, + &self.proof, + self.proof_observation.clone(), + serial, + ); + self.proof.set_paintable(Some(&paintable)); self.root.queue_draw(); } + fn proof_frame(&self) -> Option { + self.proof_observation + .get() + .filter(|render| render.serial == self.proof_serial.get()) + .map(|render| render.frame_counter) + } + fn content_opacity(&self) -> Option { self.root.child().map(|content| content.opacity()) } @@ -394,32 +418,26 @@ pub(super) fn after_next_surface_paint(widget: &W, callback: F) where W: IsA, F: FnOnce() + 'static, -{ - after_next_surface_paint_counter(widget, move |_| callback()); -} - -fn after_next_surface_paint_counter(widget: &W, callback: F) -where - W: IsA, - F: FnOnce(Option) + 'static, { let Some(frame_clock) = widget.frame_clock() else { - callback(None); + callback(); return; }; + let callback = std::rc::Rc::new(std::cell::RefCell::new(Some(callback))); let handler = std::rc::Rc::new(std::cell::RefCell::new(None)); let callback_slot = callback.clone(); let handler_slot = handler.clone(); let callback_clock = frame_clock.clone(); - let handler_id = frame_clock.connect_after_paint(move |clock| { + let handler_id = frame_clock.connect_after_paint(move |_| { if let Some(handler_id) = handler_slot.borrow_mut().take() { callback_clock.disconnect(handler_id); } if let Some(callback) = callback_slot.borrow_mut().take() { - callback(Some(clock.frame_counter())); + callback(); } }); + *handler.borrow_mut() = Some(handler_id); widget.queue_draw(); frame_clock.request_phase(gtk4::gdk::FrameClockPhase::PAINT); diff --git a/tools/csharp-tests/GtkGateParityTests.cs b/tools/csharp-tests/GtkGateParityTests.cs new file mode 100644 index 000000000..ed06e3d1a --- /dev/null +++ b/tools/csharp-tests/GtkGateParityTests.cs @@ -0,0 +1,163 @@ +using System.Net.Sockets; +using System.Runtime.Versioning; +using Xunit; + +namespace Wayscriber.Tools.Tests; + +public sealed class GtkGateParityTests +{ + private const string PopupMarker = "EXECUTED: GTK native popup presentation regression"; + private const string MenuMarker = "EXECUTED: GTK native menu presentation assertions"; + private const string FixtureSocket = "WAYSCRIBER_FIXTURE_SOCKET"; + private const string FixtureOutput = "WAYSCRIBER_FIXTURE_OUTPUT"; + private const string FixtureExit = "WAYSCRIBER_FIXTURE_EXIT"; + private const string FixtureRuntime = "WAYSCRIBER_FIXTURE_RUNTIME"; + private const string ShellProgram = "/usr/bin/bash"; + + [Theory] + [InlineData( null, ExitCodes.Success )] + [InlineData( PopupMarker, ExitCodes.Success )] + [InlineData( MenuMarker, ExitCodes.Success )] + [InlineData( null, ExitCodes.Failure )] + public async Task BothGatesRequireNativeAssertionsAndCleanUpTheirPrivateDisplay( string? omittedMarker, int childExitCode ) + { + if ( !OperatingSystem.IsLinux( ) ) + { + return; + } + + using var fixture = new GtkGateFixture( omittedMarker, childExitCode ); + var csharp = await fixture.RunCsharp( ); + var csharpRuntime = File.ReadAllText( fixture.RuntimeLog ); + Assert.False( Directory.Exists( csharpRuntime ), "C# gate must clean up its private runtime" ); + + var shell = await fixture.RunShell( ); + var shellRuntime = File.ReadAllText( fixture.RuntimeLog ); + Assert.False( Directory.Exists( shellRuntime ), "shell gate must clean up its private runtime" ); + Assert.NotEqual( csharpRuntime, shellRuntime ); + + var expected = omittedMarker is null && childExitCode == ExitCodes.Success ? ExitCodes.Success : ExitCodes.Failure; + Assert.Equal( expected, csharp.ExitCode ); + Assert.Equal( expected, shell.ExitCode ); + if ( omittedMarker is not null ) + { + Assert.Contains( omittedMarker, csharp.StandardError, StringComparison.Ordinal ); + } + } + + [SupportedOSPlatform( "linux" )] + private sealed class GtkGateFixture : IProcessRunner, IDisposable + { + private readonly TemporaryDirectory _directory = new( "wayscriber-gtk-gate-parity" ); + private readonly Socket _socket = new( AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified ); + private readonly ProcessRunner _runner = new( TextWriter.Null, TextWriter.Null ); + private readonly Dictionary _environment; + private readonly string _bin; + + public GtkGateFixture( string? omittedMarker, int childExitCode ) + { + _bin = Path.Combine( _directory.Path, "bin" ); + Directory.CreateDirectory( _bin ); + var tools = Path.Combine( _directory.Path, "tools" ); + Directory.CreateDirectory( tools ); + File.Copy( Path.Combine( FindRepository( ), "tools/test-gtk-widgets.sh" ), Path.Combine( tools, "test-gtk-widgets.sh" ) ); + + var socket = Path.Combine( _directory.Path, "socket" ); + _socket.Bind( new UnixDomainSocketEndPoint( socket ) ); + var output = Path.Combine( _directory.Path, "output" ); + string[] markers = + [ + "EXECUTED: GTK focus and slider assertions", + "EXECUTED: GTK widget contract assertions", + PopupMarker, + MenuMarker, + ]; + File.WriteAllLines( output, markers.Where( marker => marker != omittedMarker ) ); + RuntimeLog = Path.Combine( _directory.Path, "runtime" ); + _environment = new Dictionary + { + [EnvironmentVariables.Path] = _bin + Path.PathSeparator + Environment.GetEnvironmentVariable( EnvironmentVariables.Path ), + [EnvironmentVariables.Display] = ":fixture-display-must-be-removed", + [FixtureSocket] = socket, + [FixtureOutput] = output, + [FixtureExit] = childExitCode.ToString( System.Globalization.CultureInfo.InvariantCulture ), + [FixtureRuntime] = RuntimeLog, + }; + + // These stand-ins only test gate admission and cleanup. Real GTK + // presentation remains owned by the Rust tests on private Weston. + WriteExecutable( "weston", $$""" +#!/usr/bin/bash +ln -s "${{{FixtureSocket}}:?}" "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" +exec /usr/bin/sleep 30 +""" ); + WriteExecutable( "dbus-run-session", $$""" +#!/usr/bin/bash +set -euo pipefail +[[ -z "${DISPLAY:-}" ]] +[[ "$GDK_BACKEND" == wayland ]] +[[ "$WAYSCRIBER_REQUIRE_GTK_TESTS" == 1 ]] +[[ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]] +printf '%s' "$XDG_RUNTIME_DIR" > "${{{FixtureRuntime}}:?}" +cat "${{{FixtureOutput}}:?}" +exit "${{{FixtureExit}}:?}" +""" ); + } + + public string RuntimeLog { get; } + + public async Task RunCsharp( ) + { + using var output = new StringWriter( ); + using var error = new StringWriter( ); + var context = new ToolContext( _directory.Path, output, error, this, CancellationToken.None ); + var command = DevelopmentCommands.Commands.Single( item => item.Name == CommandNames.GtkWidgets ); + + try + { + var result = await command.Handler( context, [] ); + return new ProcessResult( result, output.ToString( ), error.ToString( ) ); + } + catch ( ToolException failure ) + { + return new ProcessResult( failure.ExitCode, output.ToString( ), failure.Message ); + } + } + + public Task RunShell( ) => _runner.RunAsync( new ProcessRequest( + ShellProgram, [Path.Combine( _directory.Path, "tools/test-gtk-widgets.sh" )], _directory.Path, + _environment, CaptureOutput: true, Trace: false, AllowedExitCodes: new HashSet { ExitCodes.Success, ExitCodes.Failure } ), + CancellationToken.None ); + + public Task RunAsync( ProcessRequest request, CancellationToken cancellationToken ) + { + var environment = new Dictionary( _environment ); + foreach ( var pair in request.Environment! ) + { + environment[pair.Key] = pair.Value; + } + + return _runner.RunAsync( request with { FileName = Path.Combine( _bin, request.FileName ), Environment = environment }, + cancellationToken ); + } + + public void Dispose( ) + { + _socket.Dispose( ); + _directory.Dispose( ); + } + + private void WriteExecutable( string name, string content ) + { + var path = Path.Combine( _bin, name ); + File.WriteAllText( path, content ); + File.SetUnixFileMode( path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute ); + } + } + + private static string FindRepository( ) + { + var directory = AppContext.GetData( "EntryPointFileDirectoryPath" ) as string ?? Environment.CurrentDirectory; + return Path.GetFullPath( Path.Combine( directory, ".." ) ); + } +} diff --git a/tools/csharp-tests/includes.cs b/tools/csharp-tests/includes.cs index c20c71723..360acde99 100644 --- a/tools/csharp-tests/includes.cs +++ b/tools/csharp-tests/includes.cs @@ -1,4 +1,5 @@ #:include CoreTests.cs +#:include GtkGateParityTests.cs #:include RepositoryContractTests.cs #:include ReleaseParityRegressionTests.cs #:include ReleaseRegressionTests.cs diff --git a/tools/csharp/Commands/DevelopmentCommands.cs b/tools/csharp/Commands/DevelopmentCommands.cs index a1149fd57..d614de18c 100644 --- a/tools/csharp/Commands/DevelopmentCommands.cs +++ b/tools/csharp/Commands/DevelopmentCommands.cs @@ -161,6 +161,7 @@ private static async Task RunIsolatedRenderTests( ToolContext context, string fe private static async Task GtkWidgets( ToolContext context, string[] args ) { new Arguments( args ).RequireEmpty( "ci gtk-widgets" ); + using var runtime = new TemporaryDirectory( "wayscriber-gtk-tests" ); const string display = "wayscriber-widget-tests"; var environment = new Dictionary @@ -172,6 +173,7 @@ private static async Task GtkWidgets( ToolContext context, string[] args ) [EnvironmentVariables.GtkAccessibility] = "test", [EnvironmentVariables.WayscriberRequireGtkTests] = EnvironmentVariables.Enabled, }; + using var westonCancellation = CancellationTokenSource.CreateLinkedTokenSource( context.CancellationToken ); var weston = context.Processes.RunAsync( new ProcessRequest( Programs.Weston, ["--backend=headless-backend.so", "--renderer=pixman", "--no-config", $"--socket={display}", "--idle-time=0", $"--log={Path.Combine( runtime.Path, "weston.log" )}"], @@ -192,19 +194,23 @@ private static async Task GtkWidgets( ToolContext context, string[] args ) { throw new ToolException( "Weston did not create its Wayland socket." ); } + var result = await context.Run( Programs.DbusRunSession, [CommandLineOptions.EndOfOptions, Programs.Cargo, CommandNames.Test, CommandLineOptions.Locked, "-p", RepositoryNames.MainPackage, CommandLineOptions.AllFeatures, "--lib", "toolbar_gtk::", CommandLineOptions.EndOfOptions, CommandLineOptions.SingleTestThread, "--nocapture"], environment: environment, capture: true ); await context.Output.WriteAsync( result.StandardOutput ); await context.Error.WriteAsync( result.StandardError ); + var combinedOutput = result.StandardOutput + result.StandardError; - foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions" } ) + foreach ( var marker in new[] { "EXECUTED: GTK focus and slider assertions", "EXECUTED: GTK widget contract assertions", + "EXECUTED: GTK native popup presentation regression", "EXECUTED: GTK native menu presentation assertions" } ) { if ( !combinedOutput.Contains( marker, StringComparison.Ordinal ) ) { throw new ToolException( $"GTK test output lacks marker: {marker}" ); } } + return ExitCodes.Success; } finally @@ -229,7 +235,7 @@ private static async Task InstallDependencies( ToolContext context, string[ { "checks" => common.Concat( ["clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ) .Concat( repositoryPackages ), - "widgets" => common.Concat( ["weston", "dbus-x11", "fonts-dejavu-core", "clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ), + "widgets" => common.Concat( ["weston", "dbus-x11", "python3", "libgl1-mesa-dri", "fonts-dejavu-core", "clang", "cmake", "libxkbcommon-x11-dev", "libegl1-mesa-dev", "libgles2-mesa-dev", "libdbus-1-dev", "libinput-dev", "libudev-dev", "libpixman-1-dev", "libxcb-randr0-dev"] ), "package" => common.Concat( ["rpm"] ), "repositories" => repositoryPackages, _ => null, diff --git a/tools/test-fixtures/gtk_popup_wayland.py b/tools/test-fixtures/gtk_popup_wayland.py new file mode 100644 index 000000000..e559ff5f0 --- /dev/null +++ b/tools/test-fixtures/gtk_popup_wayland.py @@ -0,0 +1,277 @@ +"""Run a real GTK popup behind a private Weston with one frame event withheld. + +All protocol messages and SCM_RIGHTS descriptors pass through unchanged, except +the selected popup callback and its delete_id event. The XML schemas provide +opcodes and new-object types; this fixture neither renders nor invents feedback. +""" + +import array +import json +import os +from pathlib import Path +import signal +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import xml.etree.ElementTree as ET + + +def schemas(): + interfaces = {} + for package, relative in (("wayland-client", "wayland.xml"), + ("wayland-protocols", "stable/xdg-shell/xdg-shell.xml")): + directory = subprocess.check_output( + ["pkg-config", "--variable=pkgdatadir", package], text=True + ).strip() + for interface in ET.parse(Path(directory) / relative).getroot().findall("interface"): + interfaces[interface.attrib["name"]] = { + direction: interface.findall(tag) + for direction, tag in (("request", "request"), ("event", "event")) + } + return interfaces + + +def arguments(message, payload): + values = {} + offset = 0 + for argument in message.findall("arg"): + kind = argument.attrib["type"] + if kind == "fd": + continue + value = struct.unpack_from("=I", payload, offset)[0] + offset += 4 + if kind in ("string", "array"): + raw = payload[offset:offset + value] + offset += (value + 3) & ~3 + value = raw.rstrip(b"\0").decode() if kind == "string" else raw + elif kind == "new_id" and "interface" not in argument.attrib: + # A dynamic new_id carries its interface name, version, then ID. + interface = payload[offset:offset + value].rstrip(b"\0").decode() + offset += (value + 3) & ~3 + version, value = struct.unpack_from("=II", payload, offset) + offset += 8 + values["interface"] = interface + values["version"] = version + values[argument.attrib["name"]] = value + return values + + +def receive(sock): + data = bytearray() + fds = array.array("i") + size = 8 + while len(data) < size: + chunk, ancillary, flags, _ = sock.recvmsg( + size - len(data), socket.CMSG_SPACE(256 * fds.itemsize) + ) + if not chunk: + for fd in fds: + os.close(fd) + return None + if flags & socket.MSG_CTRUNC: + raise RuntimeError("truncated Wayland descriptors") + data.extend(chunk) + for level, kind, raw in ancillary: + if level == socket.SOL_SOCKET and kind == socket.SCM_RIGHTS: + fds.frombytes(raw) + if len(data) == 8: + size = struct.unpack_from("=I", data, 4)[0] >> 16 + if size < 8 or size % 4: + raise RuntimeError("invalid Wayland message length") + return bytes(data), fds + + +def send(sock, packet): + data, fds = packet + try: + ancillary = [(socket.SOL_SOCKET, socket.SCM_RIGHTS, fds)] if fds else [] + sent = sock.sendmsg([data], ancillary) + sock.sendall(data[sent:]) + finally: + for fd in fds: + os.close(fd) + + +class Connection: + def __init__(self, client, upstream, interfaces): + self.client = client + self.server = socket.socket(socket.AF_UNIX) + self.server.connect(upstream) + self.interfaces = interfaces + self.objects = {1: "wl_display"} + self.xdg_surfaces = {} + self.popups = set() + self.callbacks = {} + self.lock = threading.RLock() + self.armed = False + self.selected = None + self.held = [] + self.commits = 0 + self.delivered = 0 + self.error = None + + def start(self): + for source, destination, direction in ( + (self.client, self.server, "request"), (self.server, self.client, "event") + ): + threading.Thread(target=self.forward, args=(source, destination, direction), + daemon=True).start() + + def inspect(self, packet, direction): + data, _ = packet + object_id, header = struct.unpack_from("=II", data) + interface = self.objects.get(object_id) + if interface not in self.interfaces: + return False + message = self.interfaces[interface][direction][header & 0xffff] + values = arguments(message, data[8:]) + for argument in message.findall("arg"): + if argument.attrib["type"] == "new_id": + self.objects[values[argument.attrib["name"]]] = argument.attrib.get( + "interface", values.get("interface") + ) + name = message.attrib["name"] + + if direction == "request": + if interface == "xdg_wm_base" and name == "get_xdg_surface": + self.xdg_surfaces[values["id"]] = values["surface"] + elif interface == "xdg_surface" and name == "get_popup": + self.popups.add(self.xdg_surfaces[object_id]) + elif interface == "wl_surface" and name == "frame": + self.callbacks[values["callback"]] = object_id + if self.armed and object_id in self.popups: + self.selected = values["callback"] + self.armed = False + elif interface == "wl_surface" and name == "commit" and object_id in self.popups: + self.commits += 1 + return False + + if interface == "wl_callback" and name == "done": + if object_id == self.selected: + return True + if self.callbacks.pop(object_id, None) in self.popups: + self.delivered += 1 + elif interface == "wl_display" and name == "delete_id": + if values["id"] == self.selected: + return True + self.objects.pop(values["id"], None) + return False + + def forward(self, source, destination, direction): + try: + while (packet := receive(source)) is not None: + with self.lock: + if self.inspect(packet, direction): + self.held.append(packet) + else: + send(destination, packet) + except Exception as error: + with self.lock: + self.error = f"{type(error).__name__}: {error}" + + def control(self, command): + with self.lock: + if self.error: + return {"error": self.error} + if command == "arm": + self.armed = True + self.commits = 0 + self.delivered = 0 + elif command == "release": + if not self.held: + return {"error": "no popup callback held"} + self.selected = None + for packet in self.held: + self.inspect(packet, "event") + send(self.client, packet) + self.held.clear() + elif command != "status": + return {"error": "unknown control command"} + return {"held": bool(self.held), "popup_commits": self.commits, + "popup_callbacks_delivered": self.delivered} + + +def listener(path): + sock = socket.socket(socket.AF_UNIX) + sock.bind(str(path)) + sock.listen() + return sock + + +def run_fixture(directory, executable, test_name): + environment = dict(os.environ, XDG_RUNTIME_DIR=directory, WAYLAND_DISPLAY="upstream", + GDK_BACKEND="wayland", GSK_RENDERER="gl", LIBGL_ALWAYS_SOFTWARE="1", + GTK_A11Y="test", GDK_DEBUG="no-portals") + environment.pop("DISPLAY", None) + environment.pop("WAYLAND_SOCKET", None) + environment.pop("DBUS_SESSION_BUS_ADDRESS", None) + with open(Path(directory) / "weston.log", "w+") as log: + weston = subprocess.Popen( + ["weston", "--backend=headless-backend.so", "--renderer=pixman", "--no-config", + "--socket=upstream", "--idle-time=0"], env=environment, stdout=log, stderr=log + ) + try: + upstream = str(Path(directory) / "upstream") + deadline = time.monotonic() + 10 + while not Path(upstream).is_socket(): + if weston.poll() is not None or time.monotonic() >= deadline: + log.seek(0) + raise RuntimeError(log.read()) + time.sleep(0.01) + interfaces = schemas() + proxy = listener(Path(directory) / "proxy") + control = listener(Path(directory) / "control") + connections = [] + + def accept_clients(): + while True: + client, _ = proxy.accept() + connection = Connection(client, upstream, interfaces) + connections.append(connection) + connection.start() + + def accept_control(): + while True: + client, _ = control.accept() + with client, client.makefile("r") as reader: + command = reader.readline().strip() + popup_connections = [connection for connection in connections + if connection.popups] + response = popup_connections[-1].control(command) if popup_connections else { + "error": "no GTK popup connection" + } + client.sendall((json.dumps(response) + "\n").encode()) + + threading.Thread(target=accept_clients, daemon=True).start() + threading.Thread(target=accept_control, daemon=True).start() + environment.update(WAYLAND_DISPLAY="proxy", G_DEBUG="fatal-criticals", + WAYSCRIBER_GTK_WAYLAND_CONTROL=str(Path(directory) / "control")) + child = subprocess.Popen( + ["dbus-run-session", "--", executable, test_name, "--exact", "--test-threads=1", + "--nocapture"], env=environment, start_new_session=True + ) + try: + return child.wait(timeout=20) + finally: + # Includes the private bus and any services it activated. + try: + os.killpg(child.pid, signal.SIGKILL) + except ProcessLookupError: + pass + child.wait() + finally: + weston.terminate() + try: + weston.wait(timeout=3) + except subprocess.TimeoutExpired: + weston.kill() + weston.wait() + + +if __name__ == "__main__": + with tempfile.TemporaryDirectory(prefix="wayscriber-popup-test-") as directory: + raise SystemExit(run_fixture(directory, *sys.argv[1:])) diff --git a/tools/test-gtk-widgets.sh b/tools/test-gtk-widgets.sh index 577449f5f..80e68966b 100755 --- a/tools/test-gtk-widgets.sh +++ b/tools/test-gtk-widgets.sh @@ -36,3 +36,5 @@ dbus-run-session -- cargo test --locked -p wayscriber --all-features --lib \ toolbar_gtk:: -- --test-threads=1 --nocapture 2>&1 | tee "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK focus and slider assertions' "$GTK_TEST_RUNTIME/tests.log" grep -Fq 'EXECUTED: GTK widget contract assertions' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK native popup presentation regression' "$GTK_TEST_RUNTIME/tests.log" +grep -Fq 'EXECUTED: GTK native menu presentation assertions' "$GTK_TEST_RUNTIME/tests.log"