use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossbeam_channel::{Receiver, RecvTimeoutError};
use bevy_react::animations::AnimationCommand;
use bevy_react::js_thread::spawn_js_thread;
use bevy_react::protocol::{Op, Outbound, UiEvent};
use bevy_react::{RawRequest, ReactMessage};
fn example_bundle() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples/demos/ui/dist/app.js")
}
fn accumulate(
op: &Op,
buttons: &mut HashSet<u32>,
parent_of: &mut HashMap<u32, u32>,
text_of: &mut HashMap<u32, String>,
) {
match op {
Op::Create { id, kind, text, .. } => {
if kind == "button" {
buttons.insert(*id);
}
if let Some(text) = text {
text_of.insert(*id, text.clone());
}
}
Op::CreateTextSpan { id, text } | Op::CreateText { id, text } => {
text_of.insert(*id, text.clone());
}
Op::Append { parent, child } => {
parent_of.insert(*child, *parent);
}
Op::Insert { parent, child, .. } => {
parent_of.insert(*child, *parent);
}
_ => {}
}
}
fn find_button(
label: &str,
buttons: &HashSet<u32>,
parent_of: &HashMap<u32, u32>,
text_of: &HashMap<u32, String>,
) -> Option<u32> {
for (span, text) in text_of {
if text.trim() != label {
continue;
}
let mut current = *span;
for _ in 0..8 {
let Some(&parent) = parent_of.get(¤t) else {
break;
};
if buttons.contains(&parent) {
return Some(parent);
}
current = parent;
}
}
None
}
fn drain_until_button(
ops_rx: &Receiver<Vec<Op>>,
label: &str,
dur: Duration,
buttons: &mut HashSet<u32>,
parent_of: &mut HashMap<u32, u32>,
text_of: &mut HashMap<u32, String>,
) -> Option<u32> {
let deadline = Instant::now() + dur;
loop {
if let Some(button) = find_button(label, buttons, parent_of, text_of) {
return Some(button);
}
if Instant::now() >= deadline {
return None;
}
match ops_rx.recv_timeout(Duration::from_millis(100)) {
Ok(batch) => {
for op in &batch {
accumulate(op, buttons, parent_of, text_of);
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => panic!("JS thread died during nav"),
}
}
}
#[test]
fn bridge_round_trip() {
let bundle = example_bundle();
if !bundle.exists() {
eprintln!(
"skipping bridge_round_trip: bundle not built at {}\n run: npm install && npm run build -w demos",
bundle.display()
);
return;
}
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (emit_tx, _emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
let (request_tx, _request_rx) = crossbeam_channel::unbounded::<RawRequest>();
let (anim_tx, _anim_rx) = crossbeam_channel::unbounded();
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_reload_tx, reload_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let vendor = bundle.with_file_name("vendor.js");
spawn_js_thread(
vendor,
bundle,
ops_tx,
emit_tx,
request_tx,
anim_tx,
outbound_rx,
reload_rx,
);
let mut buttons: HashSet<u32> = HashSet::new();
let mut parent_of: HashMap<u32, u32> = HashMap::new();
let mut text_of: HashMap<u32, String> = HashMap::new();
let click = |id: u32| {
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before click");
};
let comm = drain_until_button(
&ops_rx,
"Communication",
Duration::from_secs(15),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no 'Communication' nav button in initial render");
click(comm);
let basic = drain_until_button(
&ops_rx,
"Bevy <- React",
Duration::from_secs(10),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no 'Bevy <- React' nav button after expanding 'Communication'");
click(basic);
let mut plus_text: Option<u32> = None;
let mut button_id: Option<u32> = None;
let mut saw_initial = false;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && !(button_id.is_some() && saw_initial) {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(500)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
match op {
Op::Create {
id,
props,
kind,
text,
} => {
if kind == "button" {
assert!(props.on_click, "button created without onClick");
}
match text.as_deref().map(str::trim) {
Some("+") => plus_text = Some(*id),
Some("3") => saw_initial = true,
_ => {}
}
}
Op::CreateText { id, text } if text.trim() == "+" => {
plus_text = Some(*id);
}
Op::CreateText { text, .. } | Op::CreateTextSpan { text, .. }
if text.trim() == "3" =>
{
saw_initial = true;
}
_ => {}
}
}
if button_id.is_none()
&& let Some(parent) = plus_text.and_then(|t| parent_of.get(&t))
&& buttons.contains(parent)
{
button_id = Some(*parent);
}
}
}
let button_id = button_id.expect("no '+' button in counter demo");
assert!(saw_initial, "initial count '3' not rendered");
eprintln!("OK counter render: '+' button id={button_id}, count '3' present");
click(button_id);
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(500)) {
for op in &batch {
if let Op::UpdateText { text, .. } = op
&& text.trim() == "4"
{
eprintln!("OK click round trip: count updated to '4'");
eprintln!("PASS bridge end-to-end");
return;
}
}
}
}
panic!("no count '4' update after click");
}
#[test]
fn animation_callback_round_trip() {
let bundle = example_bundle();
if !bundle.exists() {
eprintln!(
"skipping animation_callback_round_trip: bundle not built at {}\n run: npm install && npm run build -w demos",
bundle.display()
);
return;
}
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (emit_tx, _emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
let (request_tx, _request_rx) = crossbeam_channel::unbounded::<RawRequest>();
let (anim_tx, anim_rx) = crossbeam_channel::unbounded::<AnimationCommand>();
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_reload_tx, reload_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let vendor = bundle.with_file_name("vendor.js");
spawn_js_thread(
vendor,
bundle,
ops_tx,
emit_tx,
request_tx,
anim_tx,
outbound_rx,
reload_rx,
);
let mut buttons: HashSet<u32> = HashSet::new();
let mut parent_of: HashMap<u32, u32> = HashMap::new();
let mut text_of: HashMap<u32, String> = HashMap::new();
let click = |id: u32| {
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before click");
};
let animations = drain_until_button(
&ops_rx,
"Animations",
Duration::from_secs(15),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no 'Animations' nav button in initial render");
click(animations);
let sequence = drain_until_button(
&ops_rx,
"Sequence",
Duration::from_secs(10),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no 'Sequence' nav button after expanding 'Animations'");
click(sequence);
let play = drain_until_button(
&ops_rx,
"Play",
Duration::from_secs(10),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no 'Play' button in the Sequence demo");
click(play);
let deadline = Instant::now() + Duration::from_secs(10);
let (value_id, token) = loop {
assert!(Instant::now() < deadline, "no tokened animate after Play");
match anim_rx.recv_timeout(Duration::from_millis(500)) {
Ok(AnimationCommand::Animate {
id,
token: Some(token),
..
}) => break (id, token),
Ok(_) => {}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => panic!("JS thread died before animate"),
}
};
eprintln!("OK Play assigned driver: shared value {value_id}, token {token}");
outbound_tx
.send(Outbound::AnimationFinished {
id: value_id,
token,
finished: true,
})
.expect("JS thread gone before settlement");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(500)) {
for op in &batch {
if let Op::UpdateText { text, .. } = op
&& text.trim() == "Play"
{
eprintln!("OK completion callback re-render: label back to 'Play'");
eprintln!("PASS animation callback end-to-end");
return;
}
}
}
}
panic!("no 'Play' label update after AnimationFinished — callback never fired");
}
#[test]
fn canvas_resize_replay_round_trip() {
use bevy_react::protocol::DrawCmd;
let bundle = example_bundle();
if !bundle.exists() {
eprintln!(
"skipping canvas_resize_replay_round_trip: bundle not built at {}\n run: npm install && npm run build -w demos",
bundle.display()
);
return;
}
let (ops_tx, ops_rx) = crossbeam_channel::unbounded::<Vec<Op>>();
let (emit_tx, _emit_rx) = crossbeam_channel::unbounded::<ReactMessage>();
let (request_tx, _request_rx) = crossbeam_channel::unbounded::<RawRequest>();
let (anim_tx, _anim_rx) = crossbeam_channel::unbounded();
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::unbounded_channel::<Outbound>();
let (_reload_tx, reload_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
let vendor = bundle.with_file_name("vendor.js");
spawn_js_thread(
vendor,
bundle,
ops_tx,
emit_tx,
request_tx,
anim_tx,
outbound_rx,
reload_rx,
);
let mut buttons: HashSet<u32> = HashSet::new();
let mut parent_of: HashMap<u32, u32> = HashMap::new();
let mut text_of: HashMap<u32, String> = HashMap::new();
let canvas_nav = drain_until_button(
&ops_rx,
"<canvas>",
Duration::from_secs(15),
&mut buttons,
&mut parent_of,
&mut text_of,
)
.expect("no '<canvas>' nav button in initial render");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: canvas_nav,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before nav click");
let mut canvas_id: Option<u32> = None;
let deadline = Instant::now() + Duration::from_secs(10);
while canvas_id.is_none() && Instant::now() < deadline {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(500)) {
for op in &batch {
if let Op::Create { id, kind, .. } = op
&& kind == "canvas"
{
canvas_id = Some(*id);
break;
}
}
}
}
let canvas_id = canvas_id.expect("no canvas create op in the '<canvas>' demo");
eprintln!("OK canvas mounted: id={canvas_id}");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: canvas_id,
kind: "resize".into(),
width: Some(460.0),
height: Some(260.0),
..Default::default()
},
})
.expect("JS thread gone before resize");
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(500)) {
for op in &batch {
if let Op::Draw { id, cmds } = op
&& *id == canvas_id
{
assert_eq!(
cmds.first(),
Some(&DrawCmd::Clear),
"resize replay must lead with a clear"
);
assert!(cmds.len() > 1, "resize replay recorded no drawing");
eprintln!("OK resize replay: draw op with {} commands", cmds.len());
eprintln!("PASS canvas resize end-to-end");
return;
}
}
}
}
panic!("no draw op after resize — declarative painter never replayed");
}