use bevy::platform::collections::HashSet;
use bevy::prelude::*;
use crate::event::ReactEvents;
use crate::protocol::NodeId;
use crate::reconcile::OpApplyStats;
use crate::{react_event, react_message};
use super::DevtoolsState;
#[react_event(name = "devtools.warning")]
struct DevtoolsWarning {
id: Option<NodeId>,
kind: String,
value: String,
message: String,
}
#[react_event(name = "devtools.console")]
struct DevtoolsConsole {
entries: Vec<DevtoolsConsoleEntry>,
}
#[derive(serde::Serialize, ts_rs::TS, Debug, Clone, PartialEq)]
struct DevtoolsConsoleEntry {
seq: u64,
time_ms: u64,
source: String,
level: String,
message: String,
}
#[react_message(name = "devtools.consoleOpen")]
pub(super) struct DevtoolsConsoleOpenMessage {
pub(super) on: bool,
}
#[react_message(name = "devtools.consoleClear")]
pub(super) struct DevtoolsConsoleClearMessage {}
pub(super) fn on_console_open_message(
msg: On<DevtoolsConsoleOpenMessage>,
mut state: ResMut<DevtoolsState>,
) {
state.console_tab_open = msg.event().on;
state.console_last_seq = None;
}
pub(super) fn on_console_clear_message(_msg: On<DevtoolsConsoleClearMessage>) {
crate::console_log::clear();
}
pub(super) fn emit_console(mut state: ResMut<DevtoolsState>, events: ReactEvents) {
if !(state.open && state.console_tab_open) {
return;
}
let (entries, watermark) = crate::console_log::since(state.console_last_seq.unwrap_or(0));
state.console_last_seq = Some(watermark);
if entries.is_empty() {
return;
}
events.send(&DevtoolsConsole {
entries: entries
.into_iter()
.map(|e| DevtoolsConsoleEntry {
seq: e.seq,
time_ms: e.time_ms,
source: e.source.as_str().into(),
level: e.level.as_str().into(),
message: e.message,
})
.collect(),
});
}
pub(super) fn emit_runtime_warnings(
stats: Res<OpApplyStats>,
events: ReactEvents,
mut seen: Local<HashSet<u64>>,
mut last_reset: Local<u64>,
) {
if stats.reset_count != *last_reset {
*last_reset = stats.reset_count;
seen.clear();
}
if stats.applied_count == 0 {
return;
}
for w in crate::diag::take_runtime_warnings() {
let mut hasher = std::hash::DefaultHasher::new();
std::hash::Hash::hash(&(w.node, w.kind, &w.value, &w.message), &mut hasher);
if seen.insert(std::hash::Hasher::finish(&hasher)) {
events.send(&DevtoolsWarning {
id: w.node,
kind: w.kind.to_string(),
value: w.value,
message: w.message,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::devtools::DevtoolsConfig;
use crate::devtools::test_util::{drain_events, test_app};
use crate::protocol::outbound::Outbound;
use tokio::sync::mpsc::UnboundedReceiver;
#[cfg(debug_assertions)]
#[test]
fn runtime_warnings_emit_once_and_reset_on_reload() {
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
let _ = crate::diag::take_runtime_warnings();
app.world_mut().resource_mut::<OpApplyStats>().applied_count = 1;
let report = || {
let _scope = crate::diag::node_scope(31337);
crate::diag::report("color", "redd", "unrecognized color \"redd\"");
};
let mine = |events: &[(String, serde_json::Value)]| {
events
.iter()
.filter(|(name, v)| name == "devtools.warning" && v["id"] == 31337)
.count()
};
report();
app.update();
let events = drain_events(&mut rx);
assert_eq!(
mine(&events),
1,
"first report ships (panel closed is fine)"
);
assert!(
events.iter().any(|(name, v)| name == "devtools.warning"
&& v["kind"] == "color"
&& v["value"] == "redd"
&& v["message"].as_str().is_some_and(|m| m.contains("redd"))),
"the event carries kind/value/message"
);
report();
app.update();
assert_eq!(
mine(&drain_events(&mut rx)),
0,
"an identical re-report is deduped"
);
app.world_mut().resource_mut::<OpApplyStats>().reset_count += 1;
report();
app.update();
assert_eq!(
mine(&drain_events(&mut rx)),
1,
"a hot reload clears the dedup set so warnings re-flag"
);
}
#[test]
fn console_stream_backlog_then_increment() {
use crate::console_log::{Level, Source};
let (mut app, mut rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
let mine = |v: &serde_json::Value| -> Vec<String> {
v["entries"]
.as_array()
.unwrap()
.iter()
.filter_map(|e| e["message"].as_str())
.filter(|m| m.contains("cons-t1-"))
.map(String::from)
.collect()
};
let payloads = |rx: &mut UnboundedReceiver<Outbound>| {
drain_events(rx)
.into_iter()
.filter(|(name, _)| name == "devtools.console")
.map(|(_, v)| v)
.collect::<Vec<_>>()
};
crate::console_log::push(Source::Rust, Level::Warn, "cons-t1-a");
crate::console_log::push(Source::Js, Level::Error, "cons-t1-b");
app.update();
assert!(
payloads(&mut rx).iter().all(|v| mine(v).is_empty()),
"closed panel: no console stream"
);
app.world_mut().resource_mut::<DevtoolsState>().open = true;
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: true });
app.update();
let sent = payloads(&mut rx);
let marked: Vec<String> = sent.iter().flat_map(&mine).collect();
assert_eq!(
marked,
vec!["cons-t1-a".to_string(), "cons-t1-b".to_string()],
"tab open sends the backlog, oldest first"
);
let entry = sent
.iter()
.flat_map(|v| v["entries"].as_array().unwrap().clone())
.find(|e| e["message"] == "cons-t1-b")
.unwrap();
assert_eq!(entry["source"], "js");
assert_eq!(entry["level"], "error");
assert!(entry["seq"].as_u64().is_some());
assert!(entry["time_ms"].as_u64().is_some());
crate::console_log::push(Source::Js, Level::Info, "cons-t1-c");
app.update();
let marked: Vec<String> = payloads(&mut rx).iter().flat_map(&mine).collect();
assert_eq!(
marked,
vec!["cons-t1-c".to_string()],
"later frames send only new entries"
);
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: false });
app.update();
assert!(payloads(&mut rx).iter().all(|v| mine(v).is_empty()));
app.world_mut()
.trigger(DevtoolsConsoleOpenMessage { on: true });
app.update();
let marked: Vec<String> = payloads(&mut rx).iter().flat_map(mine).collect();
assert_eq!(
marked,
vec![
"cons-t1-a".to_string(),
"cons-t1-b".to_string(),
"cons-t1-c".to_string()
],
"a re-shown tab gets the full backlog again"
);
}
#[test]
fn console_clear_message_empties_ring() {
use crate::console_log::{Level, Source};
let (mut app, _rx) = test_app(DevtoolsConfig {
settings_path: None,
..default()
});
crate::console_log::push(Source::Js, Level::Info, "cons-t2-before");
let (_, watermark_before) = crate::console_log::since(0);
app.world_mut().trigger(DevtoolsConsoleClearMessage {});
let (entries, _) = crate::console_log::since(0);
assert!(
entries.iter().all(|e| !e.message.contains("cons-t2-")),
"clear must drop our entry"
);
crate::console_log::push(Source::Js, Level::Info, "cons-t2-after");
let (entries, _) = crate::console_log::since(0);
let after = entries
.iter()
.find(|e| e.message == "cons-t2-after")
.expect("post-clear pushes land");
assert!(
after.seq > watermark_before,
"seq keeps counting across a clear"
);
}
}