Add headless mode for automated testing

Implemented headless mode using MinimalPlugins and ScheduleRunnerPlugin
to enable running the app without rendering, controlled via Unix socket.

Changes:
- Added conditional compilation based on 'headless' feature flag
- Use MinimalPlugins with 60 FPS ScheduleRunner in headless mode
- Skip rendering plugins (Camera, Rendering, DebugUI, SessionUI)
- Made cube mesh/material assets optional for headless spawning
- Direct NetworkingPlugin + PersistencePlugin instead of MarathonPlugin
- Use app.run() instead of platform executor in headless mode

This enables:
- Running multiple instances for multi-client testing
- Automated testing via marathonctl without GUI overhead
- Background server instances for development
- CI/CD integration for networking tests

Refs #131, #132

Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
This commit is contained in:
2025-12-24 11:50:56 +00:00
parent 6303c4b409
commit da886452bd
2 changed files with 106 additions and 46 deletions

View File

@@ -50,8 +50,8 @@ impl Plugin for CubePlugin {
fn handle_spawn_cube( fn handle_spawn_cube(
mut commands: Commands, mut commands: Commands,
mut messages: MessageReader<SpawnCubeEvent>, mut messages: MessageReader<SpawnCubeEvent>,
mut meshes: ResMut<Assets<Mesh>>, mut meshes: Option<ResMut<Assets<Mesh>>>,
mut materials: ResMut<Assets<StandardMaterial>>, mut materials: Option<ResMut<Assets<StandardMaterial>>>,
node_clock: Res<NodeVectorClock>, node_clock: Res<NodeVectorClock>,
) { ) {
for event in messages.read() { for event in messages.read() {
@@ -60,16 +60,8 @@ fn handle_spawn_cube(
info!("Spawning cube {} at {:?}", entity_id, event.position); info!("Spawning cube {} at {:?}", entity_id, event.position);
commands.spawn(( let mut entity = commands.spawn((
CubeMarker, CubeMarker,
// Bevy 3D components
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.3, 0.6),
perceptual_roughness: 0.7,
metallic: 0.3,
..default()
})),
Transform::from_translation(event.position), Transform::from_translation(event.position),
GlobalTransform::default(), GlobalTransform::default(),
// Networking // Networking
@@ -81,6 +73,19 @@ fn handle_spawn_cube(
// Sync marker // Sync marker
Synced, Synced,
)); ));
// Only add rendering components if assets are available (non-headless mode)
if let (Some(ref mut meshes), Some(ref mut materials)) = (meshes.as_mut(), materials.as_mut()) {
entity.insert((
Mesh3d(meshes.add(Cuboid::new(1.0, 1.0, 1.0))),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.8, 0.3, 0.6),
perceptual_roughness: 0.7,
metallic: 0.3,
..default()
})),
));
}
} }
} }

View File

@@ -11,7 +11,13 @@ use libmarathon::{
persistence::PersistenceConfig, persistence::PersistenceConfig,
}; };
#[cfg(feature = "headless")]
use bevy::app::ScheduleRunnerPlugin;
#[cfg(feature = "headless")]
use std::time::Duration;
mod camera; mod camera;
mod control;
mod cube; mod cube;
mod debug_ui; mod debug_ui;
mod engine_bridge; mod engine_bridge;
@@ -115,46 +121,95 @@ fn main() {
eprintln!(">>> Inserting EngineBridge resource"); eprintln!(">>> Inserting EngineBridge resource");
app.insert_resource(engine_bridge); app.insert_resource(engine_bridge);
// Use DefaultPlugins but disable winit/window/input (we own those) // Plugin setup based on headless vs rendering mode
eprintln!(">>> Adding DefaultPlugins"); #[cfg(not(feature = "headless"))]
app.add_plugins( {
DefaultPlugins info!("Adding DefaultPlugins (rendering mode)");
.build() app.add_plugins(
.disable::<bevy::log::LogPlugin>() // Using tracing-subscriber DefaultPlugins
.disable::<bevy::winit::WinitPlugin>() // We own winit .build()
.disable::<bevy::window::WindowPlugin>() // We own the window .disable::<bevy::log::LogPlugin>() // Using tracing-subscriber
.disable::<bevy::input::InputPlugin>() // We provide InputEvents directly .disable::<bevy::winit::WinitPlugin>() // We own winit
.disable::<bevy::gilrs::GilrsPlugin>(), // We handle gamepad input ourselves .disable::<bevy::window::WindowPlugin>() // We own the window
); .disable::<bevy::input::InputPlugin>() // We provide InputEvents directly
eprintln!(">>> DefaultPlugins added"); .disable::<bevy::gilrs::GilrsPlugin>(), // We handle gamepad input ourselves
);
info!("DefaultPlugins added");
}
// Marathon core plugins (networking, debug UI, persistence) #[cfg(feature = "headless")]
eprintln!(">>> Adding MarathonPlugin"); {
app.add_plugins(libmarathon::MarathonPlugin::new( info!("Adding MinimalPlugins (headless mode)");
APP_NAME, app.add_plugins(
PersistenceConfig { MinimalPlugins.set(ScheduleRunnerPlugin::run_loop(
flush_interval_secs: 2, Duration::from_secs_f64(1.0 / 60.0), // 60 FPS
checkpoint_interval_secs: 30, )),
battery_adaptive: true, );
..Default::default() info!("MinimalPlugins added");
}, }
));
eprintln!(">>> MarathonPlugin added"); // Marathon core plugins based on mode
#[cfg(not(feature = "headless"))]
{
info!("Adding MarathonPlugin (with debug UI)");
app.add_plugins(libmarathon::MarathonPlugin::new(
APP_NAME,
PersistenceConfig {
flush_interval_secs: 2,
checkpoint_interval_secs: 30,
battery_adaptive: true,
..Default::default()
},
));
}
#[cfg(feature = "headless")]
{
info!("Adding networking and persistence (headless, no UI)");
app.add_plugins(libmarathon::networking::NetworkingPlugin::new(Default::default()));
app.add_plugins(libmarathon::persistence::PersistencePlugin::with_config(
db_path.clone(),
PersistenceConfig {
flush_interval_secs: 2,
checkpoint_interval_secs: 30,
battery_adaptive: true,
..Default::default()
},
));
}
info!("Marathon plugins added");
// App-specific bridge for polling engine events // App-specific bridge for polling engine events
eprintln!(">>> Adding app plugins"); info!("Adding app plugins");
app.add_plugins(EngineBridgePlugin); app.add_plugins(EngineBridgePlugin);
app.add_plugins(CameraPlugin);
app.add_plugins(RenderingPlugin);
app.add_plugins(input::InputHandlerPlugin);
app.add_plugins(CubePlugin); app.add_plugins(CubePlugin);
app.add_plugins(SelectionPlugin);
app.add_plugins(DebugUiPlugin);
app.add_plugins(SessionUiPlugin);
app.add_systems(Startup, initialize_offline_resources); app.add_systems(Startup, initialize_offline_resources);
eprintln!(">>> All plugins added"); app.add_systems(Startup, control::start_control_socket_system);
eprintln!(">>> Running executor"); // Rendering-only plugins
libmarathon::platform::run_executor(app).expect("Failed to run executor"); #[cfg(not(feature = "headless"))]
eprintln!(">>> Executor returned (should never reach here)"); {
app.add_plugins(CameraPlugin);
app.add_plugins(RenderingPlugin);
app.add_plugins(input::InputHandlerPlugin);
app.add_plugins(SelectionPlugin);
app.add_plugins(DebugUiPlugin);
app.add_plugins(SessionUiPlugin);
}
info!("All plugins added");
// Run the app based on mode
#[cfg(not(feature = "headless"))]
{
info!("Running platform executor (rendering mode)");
libmarathon::platform::run_executor(app).expect("Failed to run executor");
}
#[cfg(feature = "headless")]
{
info!("Running headless app loop");
app.run();
}
} }