Add plumbing for system testing

This commit is contained in:
Simon Hausmann 2024-05-21 13:32:37 +02:00 committed by Simon Hausmann
parent 504ff31f24
commit 4591ad8d57
12 changed files with 313 additions and 2 deletions

View file

@ -106,6 +106,7 @@ define_cargo_dependent_feature(gettext "Enable support of translations using get
define_cargo_dependent_feature(accessibility "Enable integration with operating system provided accessibility APIs" ON "NOT SLINT_FEATURE_FREESTANDING")
define_cargo_dependent_feature(testing "Enable support for testing API (experimental)" ON "NOT SLINT_FEATURE_FREESTANDING")
define_cargo_feature(experimental "Enable experimental features. (No backward compatibility guarantees)" OFF)
define_cargo_dependent_feature(system-testing "Enable system testing support (experimental)" OFF "SLINT_FEATURE_EXPERIMENTAL AND NOT SLINT_FEATURE_FREESTANDING")
if (SLINT_BUILD_RUNTIME)
if(SLINT_FEATURE_COMPILER AND NOT SLINT_COMPILER)

View file

@ -42,6 +42,7 @@ renderer-skia-vulkan = ["i-slint-backend-selector/renderer-skia-vulkan", "render
renderer-software = ["i-slint-backend-selector/renderer-software"]
gettext = ["i-slint-core/gettext-rs"]
accessibility = ["i-slint-backend-selector/accessibility"]
system-testing = ["i-slint-backend-selector/system-testing"]
std = ["image", "i-slint-core/default", "i-slint-backend-selector"]
freestanding = ["i-slint-core/libm", "i-slint-core/unsafe-single-threaded"]

View file

@ -35,12 +35,15 @@ accessibility = ["i-slint-backend-winit?/accessibility"]
raw-window-handle-06 = ["i-slint-core/raw-window-handle-06", "i-slint-backend-winit?/raw-window-handle-06"]
system-testing = ["i-slint-backend-testing/system-testing"]
# note that default enable the i-slint-backend-qt, but not its enable feature
default = ["i-slint-backend-qt", "backend-winit"]
[dependencies]
cfg-if = "1"
i-slint-core = { workspace = true }
i-slint-backend-testing = { workspace = true, optional = true }
[target.'cfg(not(target_os = "android"))'.dependencies]
i-slint-backend-winit = { workspace = true, features = ["default"], optional = true }

View file

@ -133,5 +133,20 @@ cfg_if::cfg_if! {
pub fn with_platform<R>(
f: impl FnOnce(&dyn Platform) -> Result<R, PlatformError>,
) -> Result<R, PlatformError> {
i_slint_core::with_platform(create_backend, f)
let mut platform_created = false;
let result = i_slint_core::with_platform(
|| {
let backend = create_backend();
platform_created = backend.is_ok();
backend
},
f,
);
#[cfg(feature = "system-testing")]
if result.is_ok() && platform_created {
i_slint_backend_testing::systest::init();
}
result
}

View file

@ -12,6 +12,7 @@ repository.workspace = true
rust-version.workspace = true
version.workspace = true
publish = false
build = "build.rs"
[lib]
path = "lib.rs"
@ -21,10 +22,19 @@ path = "lib.rs"
internal = []
# ffi for C++ bindings
ffi = []
system-testing = ["quick-protobuf", "pb-rs", "generational-arena", "async-net", "futures-lite", "byteorder"]
[dependencies]
i-slint-core = { workspace = true }
vtable = { workspace = true }
quick-protobuf = { version = "0.8.1", optional = true }
generational-arena = { version = "0.2.9", optional = true }
async-net = { version = "2.0.0", optional = true }
futures-lite = { version = "2.3.0", optional = true }
byteorder = { version = "1.5.0", optional = true }
[build-dependencies]
pb-rs = { version = "0.10.0", optional = true, default-features = false }
[dev-dependencies]
slint = { workspace = true, default-features = false, features = ["std", "compat-1-2"] }

View file

@ -0,0 +1,16 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
fn main() {
#[cfg(feature = "system-testing")]
{
let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap());
let proto_file = std::path::PathBuf::from(::std::env::var("CARGO_MANIFEST_DIR").unwrap())
.join("slint_systest.proto");
let config_builder = pb_rs::ConfigBuilder::new(&[proto_file], None, Some(&out_dir), &[])
.unwrap()
.headers(false)
.dont_use_cow(true);
pb_rs::types::FileDescriptor::run(&config_builder.build()).unwrap();
}
}

View file

@ -13,8 +13,10 @@ pub use internal_tests::*;
mod testing_backend;
#[cfg(feature = "internal")]
pub use testing_backend::*;
#[cfg(feature = "ffi")]
#[cfg(all(feature = "ffi", not(test)))]
mod ffi;
#[cfg(feature = "system-testing")]
pub mod systest;
/// Initialize the testing backend without support for event loop.
/// This means that each test thread can use its own backend, but global functions that needs

View file

@ -0,0 +1,64 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
syntax = "proto3";
// Common messages
// Index in generational arena in AUT
message Handle {
uint64 index = 1; // ### should use uint128 if only it were available
uint64 generation = 2;
}
// Requests
message RequestWindowListMessage {
}
message RequestWindowProperties {
Handle window_handle = 1;
}
message RequestToAUT {
oneof msg {
RequestWindowListMessage request_window_list = 1;
RequestWindowProperties request_window_properties = 2;
}
}
// Responses
message ErrorResponse {
string message = 1;
}
message WindowListResponse {
repeated Handle window_handles = 1;
}
message PhysicalSize {
uint32 width = 1;
uint32 height = 2;
}
message PhysicalPosition {
int32 x = 1;
int32 y = 2;
}
message WindowPropertiesResponse {
bool is_fullscreen = 1;
bool is_maximized = 2;
bool is_minimized = 3;
PhysicalSize size = 4;
PhysicalPosition position = 5;
}
message AUTResponse {
oneof msg {
ErrorResponse error = 1;
WindowListResponse window_list = 2;
WindowPropertiesResponse window_properties = 3;
}
}

View file

@ -0,0 +1,181 @@
// Copyright © SixtyFPS GmbH <info@slint.dev>
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use futures_lite::AsyncReadExt;
use futures_lite::AsyncWriteExt;
use i_slint_core::api::EventLoopError;
use i_slint_core::debug_log;
use i_slint_core::window::WindowAdapter;
use quick_protobuf::{MessageRead, MessageWrite};
use std::cell::RefCell;
use std::io::Cursor;
use std::rc::{Rc, Weak};
#[allow(non_snake_case, unused_imports, non_camel_case_types)]
mod proto {
include!(concat!(env!("OUT_DIR"), "/slint_systest.rs"));
}
struct TestingClient {
windows: RefCell<generational_arena::Arena<Weak<dyn WindowAdapter>>>,
message_loop_future: std::cell::OnceCell<i_slint_core::future::JoinHandle<()>>,
server_addr: String,
}
impl TestingClient {
fn new() -> Option<Rc<Self>> {
let Ok(server_addr) = std::env::var("SLINT_TEST_SERVER") else {
return None;
};
Some(Rc::new(Self {
windows: Default::default(),
message_loop_future: Default::default(),
server_addr,
}))
}
fn add_window(self: Rc<Self>, adapter: &Rc<dyn WindowAdapter>) {
self.windows.borrow_mut().insert(Rc::downgrade(adapter));
let this = self.clone();
self.message_loop_future.get_or_init(|| {
i_slint_core::future::spawn_local({
let this = this.clone();
async move {
message_loop(&this.server_addr, |request| this.handle_request(request)).await;
}
})
.unwrap()
});
}
fn handle_request(
&self,
request: proto::mod_RequestToAUT::OneOfmsg,
) -> Result<proto::mod_AUTResponse::OneOfmsg, String> {
Ok(match request {
proto::mod_RequestToAUT::OneOfmsg::request_window_list(..) => {
proto::mod_AUTResponse::OneOfmsg::window_list(proto::WindowListResponse {
window_handles: self
.windows
.borrow()
.iter()
.map(|(index, _)| index_to_handle(index))
.collect(),
})
}
proto::mod_RequestToAUT::OneOfmsg::request_window_properties(
proto::RequestWindowProperties { window_handle },
) => proto::mod_AUTResponse::OneOfmsg::window_properties(self.window_properties(
handle_to_index(window_handle.ok_or_else(|| {
"window properties request missing window handle".to_string()
})?),
)?),
proto::mod_RequestToAUT::OneOfmsg::None => return Err("Unknown request".into()),
})
}
fn window_properties(
&self,
window_index: generational_arena::Index,
) -> Result<proto::WindowPropertiesResponse, String> {
let adapter = self
.windows
.borrow()
.get(window_index)
.ok_or_else(|| "Invalid window handle".to_string())?
.upgrade()
.ok_or_else(|| "Attempting to access deleted window".to_string())?;
let window = adapter.window();
Ok(proto::WindowPropertiesResponse {
is_fullscreen: window.is_fullscreen(),
is_maximized: window.is_maximized(),
is_minimized: window.is_minimized(),
size: send_physical_size(window.size()).into(),
position: send_physical_position(window.position()).into(),
})
}
}
pub fn init() -> Result<(), EventLoopError> {
let Some(client) = TestingClient::new() else {
return Ok(());
};
i_slint_core::context::set_window_shown_hook(Some(Box::new(move |adapter| {
client.clone().add_window(adapter)
})))
.unwrap();
Ok(())
}
async fn message_loop(
server_addr: &str,
mut message_callback: impl FnMut(
proto::mod_RequestToAUT::OneOfmsg,
) -> Result<proto::mod_AUTResponse::OneOfmsg, String>,
) {
debug_log!("Attempting to connect to testing server at {server_addr}");
let mut stream = match async_net::TcpStream::connect(server_addr).await {
Ok(stream) => stream,
Err(err) => {
eprintln!("Error connecting to Slint test server at {server_addr}: {}", err);
return;
}
};
debug_log!("Connected to test server");
loop {
let mut message_size_buf = vec![0; 4];
stream
.read_exact(&mut message_size_buf)
.await
.expect("Unable to read request header from AUT connection");
let message_size: usize =
Cursor::new(message_size_buf).read_u32::<BigEndian>().unwrap() as usize;
let mut message_buf = Vec::with_capacity(message_size);
message_buf.resize(message_size, 0);
stream
.read_exact(&mut message_buf)
.await
.expect("Unable to read request data from AUT connection");
let message = proto::RequestToAUT::from_reader(
&mut quick_protobuf::reader::BytesReader::from_bytes(&message_buf),
&mut message_buf,
)
.expect("Unable to de-serialize AUT request message");
let response = message_callback(message.msg).unwrap_or_else(|message| {
proto::mod_AUTResponse::OneOfmsg::error(proto::ErrorResponse { message })
});
let response = proto::AUTResponse { msg: response };
let mut size_header = Vec::new();
size_header.write_u32::<BigEndian>(response.get_size() as u32).unwrap();
stream.write_all(&size_header).await.expect("Unable to write AUT response header");
let mut message_body = Vec::new();
response.write_message(&mut quick_protobuf::Writer::new(&mut message_body)).unwrap();
stream.write_all(&message_body).await.expect("Unable to write AUT response body");
}
}
fn index_to_handle(index: generational_arena::Index) -> proto::Handle {
let (index, generation) = index.into_raw_parts();
proto::Handle { index: index as u64, generation }
}
fn handle_to_index(handle: proto::Handle) -> generational_arena::Index {
generational_arena::Index::from_raw_parts(handle.index as usize, handle.generation)
}
fn send_physical_size(sz: i_slint_core::api::PhysicalSize) -> proto::PhysicalSize {
proto::PhysicalSize { width: sz.width, height: sz.height }
}
fn send_physical_position(pos: i_slint_core::api::PhysicalPosition) -> proto::PhysicalPosition {
proto::PhysicalPosition { x: pos.x, y: pos.y }
}

View file

@ -21,6 +21,8 @@ pub(crate) struct SlintContextInner {
/// This property is read by all translations, and marked dirty when the language change
/// so that every translated string gets re-translated
pub(crate) translations_dirty: core::pin::Pin<Box<Property<()>>>,
pub(crate) window_shown_hook:
core::cell::RefCell<Option<Box<dyn FnMut(&Rc<dyn crate::platform::WindowAdapter>)>>>,
}
/// This context is meant to hold the state and the backend.
@ -36,6 +38,7 @@ impl SlintContext {
platform,
window_count: 0.into(),
translations_dirty: Box::pin(Property::new_named((), "SlintContext::translations")),
window_shown_hook: Default::default(),
}))
}
@ -74,3 +77,14 @@ pub fn with_platform<R>(
}
})
}
/// Internal function to set a hook that's invoked whenever a slint::Window is shown. This
/// is used by the system testing module. Returns a previously set hook, if any.
pub fn set_window_shown_hook(
hook: Option<Box<dyn FnMut(&Rc<dyn crate::platform::WindowAdapter>)>>,
) -> Result<Option<Box<dyn FnMut(&Rc<dyn crate::platform::WindowAdapter>)>>, PlatformError> {
GLOBAL_CONTEXT.with(|p| match p.get() {
Some(ctx) => Ok(ctx.0.window_shown_hook.replace(hook)),
None => Err(PlatformError::NoPlatform),
})
}

View file

@ -903,6 +903,9 @@ impl WindowInner {
let size = self.window_adapter().size();
self.set_window_item_geometry(size.to_logical(self.scale_factor()).to_euclid());
self.window_adapter().renderer().resize(size).unwrap();
if let Some(hook) = self.ctx.0.window_shown_hook.borrow_mut().as_mut() {
hook(&self.window_adapter());
}
Ok(())
}

View file

@ -485,6 +485,7 @@ lazy_static! {
("\\.yaml$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
("\\.yml$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
("\\.py$", LicenseLocation::Tag(LicenseTagStyle::shell_comment_style())),
("\\.proto$", LicenseLocation::Tag(LicenseTagStyle::c_style_comment_style())),
]
.iter()
.map(|(re, ty)| (regex::Regex::new(re).unwrap(), *ty))