Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4813,6 +4813,30 @@ description = "A Bevy app that you can connect to with the BRP and edit"
category = "Remote Protocol"
wasm = false

[[example]]
name = "app_under_test"
path = "examples/remote/app_under_test.rs"
doc-scrape-examples = true
required-features = ["bevy_remote"]

[package.metadata.example.app_under_test]
name = "App Under Test"
description = "A Bevy app that you can connect to with the BRP and control"
category = "Remote Protocol"
wasm = false

[[example]]
name = "integration_test"
path = "examples/remote/integration_test.rs"
doc-scrape-examples = true
required-features = ["bevy_remote"]

[package.metadata.example.integration_test]
name = "Integration Test"
description = "Connects to a running Bevy app via BRP, finds a button, and clicks it"
category = "Remote Protocol"
wasm = false

[[example]]
name = "anisotropy"
path = "examples/3d/anisotropy.rs"
Expand Down
2 changes: 1 addition & 1 deletion crates/bevy_window/src/event.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ impl AppLifecycle {
#[cfg_attr(
feature = "bevy_reflect",
derive(Reflect),
reflect(Debug, PartialEq, Clone)
reflect(Debug, PartialEq, Clone, Message)
)]
#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(
Expand Down
2 changes: 2 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,8 @@ Example | Description

Example | Description
--- | ---
[App Under Test](../examples/remote/app_under_test.rs) | A Bevy app that you can connect to with the BRP and control
[Integration Test](../examples/remote/integration_test.rs) | Connects to a running Bevy app via BRP, finds a button, and clicks it
[client](../examples/remote/client.rs) | A simple command line client that can control Bevy apps via the BRP
[server](../examples/remote/server.rs) | A Bevy app that you can connect to with the BRP and edit

Expand Down
113 changes: 113 additions & 0 deletions examples/remote/app_under_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! A Bevy app that can be used as an integration test target.
//! It displays a button that must be clicked. The button is placed at a random position and
//! moves every 5 seconds.
//!
//! Run with the `bevy_remote` feature enabled:
//! ```bash
//! cargo run --example app_under_test --features="bevy_remote"
//! ```
//! This example can be paired with the `integration_test` example, which will run an integration
//! test on this app.

use bevy::{
prelude::*,
remote::{http::RemoteHttpPlugin, RemotePlugin},
time::common_conditions::on_timer,
ui::UiGlobalTransform,
};
use chacha20::ChaCha8Rng;
use rand::{RngExt, SeedableRng};

fn main() {
App::new()
.add_plugins(DefaultPlugins)
// To make the app available for integration testing, we add these
// remote plugins to expose API’s for a testing framework to call.
.add_plugins(RemotePlugin::default())
.add_plugins(RemoteHttpPlugin::default())
.insert_resource(SeededRng(ChaCha8Rng::seed_from_u64(19878367467712)))
.add_systems(Startup, setup)
.add_systems(
Update,
(
move_button.run_if(on_timer(std::time::Duration::from_secs(5))),
log_button_position,
),
)
.run();
}

#[derive(Resource)]
struct SeededRng(ChaCha8Rng);

fn on_button_click(_click: On<Pointer<Click>>, mut exit: MessageWriter<AppExit>) {
info!("Button pressed!");
exit.write(AppExit::Success);
}

fn log_button_position(
transform: Single<&UiGlobalTransform, (With<Button>, Changed<UiGlobalTransform>)>,
) {
info!(
"Button at physical ({}, {})",
transform.translation.x, transform.translation.y
);
}

fn random_position(rng: &mut ChaCha8Rng) -> (f32, f32) {
let left_pct = rng.random_range(0.0..=60.0);
let top_pct = rng.random_range(0.0..=60.0);
(left_pct, top_pct)
}

fn move_button(mut rng: ResMut<SeededRng>, mut button_query: Query<&mut Node, With<Button>>) {
let (left_pct, top_pct) = random_position(&mut rng.0);
for mut node in &mut button_query {
node.left = percent(left_pct);
node.top = percent(top_pct);
}
}

fn setup(mut commands: Commands, assets: Res<AssetServer>, mut rng: ResMut<SeededRng>) {
let (left_pct, top_pct) = random_position(&mut rng.0);

commands.spawn(Camera2d);
commands
.spawn(Node {
width: percent(100),
height: percent(100),
..default()
})
.with_children(|parent| {
parent
.spawn((
Button,
Node {
width: px(150),
height: px(65),
border: UiRect::all(px(5)),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border_radius: BorderRadius::MAX,
left: percent(left_pct),
top: percent(top_pct),
..default()
},
BorderColor::all(Color::WHITE),
BackgroundColor(Color::BLACK),
))
.observe(on_button_click)
.with_children(|parent| {
parent.spawn((
Text::new("Button"),
TextFont {
font: assets.load("fonts/FiraSans-Bold.ttf").into(),
font_size: FontSize::Px(33.0),
..default()
},
TextColor(Color::srgb(0.9, 0.9, 0.9)),
TextShadow::default(),
));
});
});
}
164 changes: 164 additions & 0 deletions examples/remote/integration_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
//! An integration test that connects to a running Bevy app via the BRP,
//! finds a button's position, and sends a mouse click to press it.
//!
//! Run with the `bevy_remote` feature enabled:
//! ```bash
//! cargo run --example integration_test --features="bevy_remote"
//! ```
//! This example assumes that the `app_under_test` example is running on the same machine.

use std::any::type_name;

use anyhow::Result as AnyhowResult;
use bevy::{
remote::{
builtin_methods::{
BrpQuery, BrpQueryFilter, BrpQueryParams, BrpWriteMessageParams, ComponentSelector,
BRP_QUERY_METHOD, BRP_WRITE_MESSAGE_METHOD,
},
http::{DEFAULT_ADDR, DEFAULT_PORT},
BrpRequest,
},
ui::{widget::Button, UiGlobalTransform},
window::{Window, WindowEvent},
};

fn main() -> AnyhowResult<()> {
let url = format!("http://{DEFAULT_ADDR}:{DEFAULT_PORT}/");

// Step 1: Find the button entity, and its global transform
println!("Querying for button entity...");
let button_query = brp_request(
&url,
BRP_QUERY_METHOD,
1,
&BrpQueryParams {
data: BrpQuery {
components: vec![type_name::<UiGlobalTransform>().to_string()],
option: ComponentSelector::default(),
has: Vec::default(),
},
strict: false,
filter: BrpQueryFilter {
with: vec![type_name::<Button>().to_string()],
without: Vec::default(),
},
},
)?;

let button_result = button_query["result"]
.as_array()
.expect("Expected result array");
let button = &button_result[0];

// UiGlobalTransform wraps an Affine2, serialized as a flat array:
// [_, _, _, _, translation_x, translation_y]
// The translation gives the node's center in physical pixels.
let transform = &button["components"][type_name::<UiGlobalTransform>()];
let transform_arr = transform.as_array().expect("Expected transform array");
let phys_x = transform_arr[4].as_f64().unwrap();
let phys_y = transform_arr[5].as_f64().unwrap();
println!("Found button at physical ({phys_x}, {phys_y})");

// Step 2: Find the window entity and scale factor
println!("Querying for window entity...");
let window_query = brp_request(
&url,
BRP_QUERY_METHOD,
2,
&BrpQueryParams {
data: BrpQuery {
components: vec![type_name::<Window>().to_string()],
option: ComponentSelector::default(),
has: Vec::default(),
},
strict: false,
filter: BrpQueryFilter::default(),
},
)?;

let window_result = window_query["result"]
.as_array()
.expect("Expected result array");
let window = &window_result[0];
let window_entity = &window["entity"];
let window_data = &window["components"][type_name::<Window>()];
let scale_factor = window_data["resolution"]["scale_factor"].as_f64().unwrap();
println!("Found window entity: {window_entity}, scale_factor: {scale_factor}");

// Step 3: Convert button center from physical to logical pixels
let logical_x = phys_x / scale_factor;
let logical_y = phys_y / scale_factor;
println!("Clicking at logical position: ({logical_x}, {logical_y})");

// Step 4: Send CursorMoved via WindowEvent message
// This lets the picking system know where the pointer is.
println!("Sending CursorMoved message...");
brp_request(
&url,
BRP_WRITE_MESSAGE_METHOD,
3,
&BrpWriteMessageParams {
message: type_name::<WindowEvent>().to_string(),
value: Some(serde_json::json!({
"CursorMoved": {
"window": window_entity,
"position": [logical_x, logical_y],
"delta": null
}
})),
},
)?;

// Step 5: Send MouseButtonInput Pressed + Released via WindowEvent messages.
// The picking system needs both press and release to generate a Pointer<Click>.
println!("Sending mouse press...");
brp_request(
&url,
BRP_WRITE_MESSAGE_METHOD,
4,
&BrpWriteMessageParams {
message: type_name::<WindowEvent>().to_string(),
value: Some(serde_json::json!({
"MouseButtonInput": {
"button": "Left",
"state": "Pressed",
"window": window_entity,
}
})),
},
)?;

println!("Sending mouse release...");
brp_request(
&url,
BRP_WRITE_MESSAGE_METHOD,
5,
&BrpWriteMessageParams {
message: type_name::<WindowEvent>().to_string(),
value: Some(serde_json::json!({
"MouseButtonInput": {
"button": "Left",
"state": "Released",
"window": window_entity,
}
})),
},
)?;

Ok(())
}

fn brp_request(
url: &str,
method: &str,
id: u32,
params: &impl serde::Serialize,
) -> AnyhowResult<serde_json::Value> {
let req = BrpRequest {
method: method.to_string(),
id: Some(serde_json::to_value(id)?),
params: Some(serde_json::to_value(params)?),
};
Ok(ureq::post(url).send_json(req)?.body_mut().read_json()?)
}