use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use crossbeam_channel::RecvTimeoutError;
use bevy_react::js_thread::spawn_js_thread;
use bevy_react::protocol::{op::Op, outbound::Outbound, 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 } | 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
}
#[test]
fn devtools_panel_round_trip() {
let bundle = example_bundle();
if !bundle.exists() {
eprintln!(
"skipping devtools_panel_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 (flush_stamps_tx, _flush_stamps_rx) = crossbeam_channel::unbounded();
let (flush_devtools_tx, flush_devtools_rx) = crossbeam_channel::unbounded();
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,
flush_stamps_tx,
flush_devtools_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 deadline = Instant::now() + Duration::from_secs(15);
while find_button("Communication", &buttons, &parent_of, &text_of).is_none() {
assert!(Instant::now() < deadline, "no initial app render");
match ops_rx.recv_timeout(Duration::from_millis(100)) {
Ok(batch) => {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => panic!("JS thread died during app mount"),
}
}
eprintln!("OK app mounted");
outbound_tx
.send(Outbound::Event {
name: "devtools.toggle".into(),
value: serde_json::json!({ "open": true }),
})
.expect("JS thread gone before toggle");
let mut root_id: Option<u32> = None;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
if root_id.is_some() && find_button("x", &buttons, &parent_of, &text_of).is_some() {
break;
}
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(200)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
if let Op::Create { id, kind, .. } = op
&& kind == "root"
{
root_id = Some(*id);
}
}
}
}
let root_id = root_id.expect("no `<root>` create op after devtools.toggle");
assert!(
text_of.values().any(|t| t.trim() == "devtools"),
"panel chrome (title) did not render"
);
let close = find_button("x", &buttons, &parent_of, &text_of)
.expect("no close (x) button in the devtools panel");
eprintln!("OK panel mounted: <root> id={root_id}, close button id={close}");
let flags: Vec<bool> = flush_devtools_rx.try_iter().collect();
assert_eq!(
flags.first(),
Some(&false),
"the app mount must cross as an app-attributed flush"
);
assert!(
flags.iter().any(|&devtools| devtools),
"the panel mount must cross as a devtools-attributed flush"
);
eprintln!("OK flush origin flags: {flags:?}");
let layers_tab = find_button("Layers", &buttons, &parent_of, &text_of)
.expect("no Layers tab button in the devtools panel");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: layers_tab,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before Layers tab click");
let mut saw_layers_open = false;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && !saw_layers_open {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(50)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
while let Ok(msg) = emit_rx.try_recv() {
if msg.name == "devtools.layersOpen" {
assert_eq!(
msg.value,
serde_json::json!({ "on": true }),
"opening the tab must announce on: true"
);
saw_layers_open = true;
}
}
}
assert!(
saw_layers_open,
"no devtools.layersOpen emit after opening the Layers tab"
);
eprintln!("OK Layers tab: layersOpen {{ on: true }} emitted");
outbound_tx
.send(Outbound::Event {
name: "devtools.layers".into(),
value: serde_json::json!({
"layers": [
{ "id": 0, "reasons": ["base"], "depth": 0, "node_count": 0,
"rect": { "x": 0.0, "y": 0.0, "width": 800.0, "height": 600.0,
"physical_width": 800, "physical_height": 600 },
"repaints": 0, "filters": [] },
{ "id": 7, "reasons": ["opacity", "filter"], "depth": 1, "node_count": 5,
"rect": { "x": 10.0, "y": 10.0, "width": 100.0, "height": 50.0,
"physical_width": 100, "physical_height": 50 },
"repaints": 0,
"filters": [
{ "name": "blur", "params": [["radius", [4.25]]] },
{ "name": "sepia", "params": [["amount", [1.0]]] },
] },
]
}),
})
.expect("JS thread gone before layers payload");
let deadline = Instant::now() + Duration::from_secs(10);
while !(text_of.values().any(|t| t.trim() == "opacity")
&& text_of
.values()
.any(|t| t.trim() == "blur(radius 4.25) sepia(amount 1)"))
{
assert!(
Instant::now() < deadline,
"the Layers panel never rendered the payload's reason pill + filter chain"
);
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(100)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
}
eprintln!("OK Layers tab rendered the payload (reason pill + filter chain)");
let console_tab = find_button("Console", &buttons, &parent_of, &text_of)
.expect("no Console tab button in the devtools panel");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: console_tab,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before Console tab click");
let mut saw_console_open = false;
let mut saw_layers_unmount = false;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && !(saw_console_open && saw_layers_unmount) {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(50)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
while let Ok(msg) = emit_rx.try_recv() {
if msg.name == "devtools.consoleOpen" {
assert_eq!(
msg.value,
serde_json::json!({ "on": true }),
"opening the tab must announce on: true"
);
saw_console_open = true;
}
if msg.name == "devtools.layersOpen" && msg.value == serde_json::json!({ "on": false })
{
saw_layers_unmount = true;
}
}
}
assert!(
saw_console_open,
"no devtools.consoleOpen emit after opening the Console tab"
);
assert!(
saw_layers_unmount,
"the Layers panel's unmount (tab switch) never reported layersOpen: false"
);
eprintln!("OK Console tab: consoleOpen {{ on: true }} emitted (Layers unmounted)");
outbound_tx
.send(Outbound::Event {
name: "devtools.console".into(),
value: serde_json::json!({
"entries": [
{ "seq": 1, "time_ms": 1753178400123u64, "source": "js",
"level": "error", "message": "boom from app" },
{ "seq": 2, "time_ms": 1753178400124u64, "source": "rust",
"level": "warn", "message": "[color] unrecognized color \"redd\"" },
]
}),
})
.expect("JS thread gone before console payload");
let deadline = Instant::now() + Duration::from_secs(10);
while !(text_of.values().any(|t| t.contains("boom from app"))
&& text_of.values().any(|t| t.contains("unrecognized color")))
{
assert!(
Instant::now() < deadline,
"the Console panel never rendered the payload's rows"
);
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(100)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
}
eprintln!("OK Console tab rendered the payload");
let clear = find_button("clear", &buttons, &parent_of, &text_of)
.expect("no clear button in the Console tab");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: clear,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before clear click");
let mut saw_clear = false;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && !saw_clear {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(50)) {
for op in &batch {
accumulate(op, &mut buttons, &mut parent_of, &mut text_of);
}
}
while let Ok(msg) = emit_rx.try_recv() {
if msg.name == "devtools.consoleClear" {
saw_clear = true;
}
}
}
assert!(
saw_clear,
"no devtools.consoleClear emit after clicking clear"
);
eprintln!("OK Console tab: clear emitted devtools.consoleClear");
outbound_tx
.send(Outbound::UiEvent {
event: UiEvent {
id: close,
kind: "click".into(),
..Default::default()
},
})
.expect("JS thread gone before close click");
let mut saw_emit = false;
let mut saw_remove = false;
let mut saw_console_close = false;
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline && !(saw_emit && saw_remove && saw_console_close) {
if let Ok(batch) = ops_rx.recv_timeout(Duration::from_millis(100)) {
for op in &batch {
if let Op::Remove { child, .. } = op
&& *child == root_id
{
saw_remove = true;
}
}
}
while let Ok(msg) = emit_rx.try_recv() {
if msg.name == "devtools.open" {
assert_eq!(
msg.value,
serde_json::json!({ "open": false }),
"close must report open: false"
);
saw_emit = true;
}
if msg.name == "devtools.consoleOpen" && msg.value == serde_json::json!({ "on": false })
{
saw_console_close = true;
}
}
}
assert!(
saw_remove,
"panel `<root>` never removed after close (emit seen: {saw_emit})"
);
assert!(
saw_emit,
"no devtools.open emit after clicking close (remove seen: {saw_remove})"
);
assert!(
saw_console_close,
"the Console panel's unmount cleanup never reported consoleOpen: false"
);
eprintln!("OK close: devtools.open {{ open: false }} emitted, <root> removed");
eprintln!("PASS devtools end-to-end");
}