mirror of
https://github.com/slint-ui/slint.git
synced 2025-08-04 18:58:36 +00:00
Add plumbing for system testing
This commit is contained in:
parent
504ff31f24
commit
4591ad8d57
12 changed files with 313 additions and 2 deletions
|
@ -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)
|
||||
|
|
|
@ -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"]
|
||||
|
|
|
@ -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 }
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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"] }
|
||||
|
|
16
internal/backends/testing/build.rs
Normal file
16
internal/backends/testing/build.rs
Normal 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();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
64
internal/backends/testing/slint_systest.proto
Normal file
64
internal/backends/testing/slint_systest.proto
Normal 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;
|
||||
}
|
||||
}
|
181
internal/backends/testing/systest.rs
Normal file
181
internal/backends/testing/systest.rs
Normal 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 }
|
||||
}
|
|
@ -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),
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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))
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue