use std::io::{BufRead, BufReader, Read, Write};
use std::process::{Child, ChildStderr, Command, Stdio};
use std::sync::mpsc;
use std::sync::{Arc, Mutex};
use std::time::Duration;
const RECV_TIMEOUT: Duration = Duration::from_secs(10);
fn send(stdin: &mut impl Write, msg: &serde_json::Value) {
let line = serde_json::to_string(msg).unwrap();
writeln!(stdin, "{line}").unwrap();
stdin.flush().unwrap();
}
struct LineReceiver {
rx: mpsc::Receiver<serde_json::Value>,
_handle: std::thread::JoinHandle<()>,
}
impl LineReceiver {
fn new(stdout: std::process::ChildStdout) -> Self {
let (tx, rx) = mpsc::channel();
let handle = std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => break, Ok(_) => {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str(trimmed) {
Ok(val) => {
if tx.send(val).is_err() {
break; }
}
Err(e) => {
panic!("failed to parse JSON from subprocess: {e}\nraw: {line:?}");
}
}
}
Err(e) => {
panic!("read_line failed: {e}");
}
}
}
});
Self {
rx,
_handle: handle,
}
}
fn recv(&self) -> serde_json::Value {
self.recv_timeout(RECV_TIMEOUT)
}
fn recv_skip_status(&self) -> serde_json::Value {
loop {
let msg = self.recv();
if msg.get("family").and_then(|f| f.as_str()) == Some("status") {
continue;
}
return msg;
}
}
fn recv_timeout(&self, timeout: Duration) -> serde_json::Value {
match self.rx.recv_timeout(timeout) {
Ok(val) => val,
Err(mpsc::RecvTimeoutError::Timeout) => {
panic!(
"recv timed out after {:.1}s waiting for subprocess response",
timeout.as_secs_f64()
);
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
panic!("subprocess stdout closed unexpectedly (reader thread exited)");
}
}
}
}
fn plushie_binary() -> String {
let mut path = std::env::current_exe().unwrap();
path.pop(); path.pop(); path.push("plushie-renderer");
path.to_string_lossy().to_string()
}
struct StderrCapture {
buffer: Arc<Mutex<Vec<u8>>>,
_handle: std::thread::JoinHandle<()>,
}
impl StderrCapture {
fn spawn(stderr: ChildStderr) -> Self {
let buffer = Arc::new(Mutex::new(Vec::new()));
let buf_for_thread = Arc::clone(&buffer);
let handle = std::thread::spawn(move || {
let mut reader = stderr;
let mut chunk = [0u8; 4096];
loop {
match reader.read(&mut chunk) {
Ok(0) => break,
Ok(n) => {
if let Ok(mut guard) = buf_for_thread.lock() {
guard.extend_from_slice(&chunk[..n]);
}
}
Err(_) => break,
}
}
});
Self {
buffer,
_handle: handle,
}
}
fn snapshot(&self) -> String {
let guard = self.buffer.lock().unwrap();
String::from_utf8_lossy(&guard).into_owned()
}
}
impl Drop for StderrCapture {
fn drop(&mut self) {
if std::thread::panicking() {
let text = self.snapshot();
if !text.is_empty() {
eprintln!("---- captured renderer stderr ----\n{text}\n---- end ----");
}
}
}
}
fn spawn_renderer(args: &[&str]) -> (Child, StderrCapture) {
let mut child = Command::new(plushie_binary())
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("failed to spawn plushie");
let stderr = child
.stderr
.take()
.expect("renderer subprocess should expose stderr");
let capture = StderrCapture::spawn(stderr);
(child, capture)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
Mock,
Headless,
}
impl Mode {
fn flag(self) -> &'static str {
match self {
Mode::Mock => "--mock",
Mode::Headless => "--headless",
}
}
fn label(self) -> &'static str {
match self {
Mode::Mock => "mock",
Mode::Headless => "headless",
}
}
}
fn run_in_modes(modes: &[Mode], body: impl Fn(Mode)) {
for &mode in modes {
let label = mode.label();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| body(mode)));
if let Err(payload) = result {
let msg = payload
.downcast_ref::<&'static str>()
.copied()
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
.unwrap_or("(non-string panic)");
panic!("test body failed in {label} mode: {msg}");
}
}
}
fn run_in_both_modes(body: impl Fn(Mode)) {
run_in_modes(&[Mode::Mock, Mode::Headless], body);
}
#[test]
fn hello_message_has_empty_session() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = stdout.recv();
assert_eq!(hello["type"], "hello");
assert_eq!(hello["session"], "");
drop(stdin);
child.wait().unwrap();
}
#[test]
fn hello_message_fields() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = stdout.recv();
assert_eq!(hello["type"], "hello");
let protocol_version = serde_json::json!(plushie_widget_sdk::protocol::PROTOCOL_VERSION);
assert_eq!(
hello["protocol_version"], protocol_version,
"hello.protocol_version should match PROTOCOL_VERSION"
);
assert_eq!(
hello["protocol"], protocol_version,
"hello.protocol should mirror hello.protocol_version"
);
assert_eq!(
hello["codec"], "json",
"hello.codec should confirm the JSON transport codec"
);
assert!(hello.get("extensions").is_none());
let native_widgets = hello["native_widgets"]
.as_array()
.expect("hello.native_widgets should be an array");
assert!(native_widgets.is_empty());
assert_eq!(hello["widget_sets"], serde_json::json!(["iced"]));
let widgets = hello["widgets"]
.as_array()
.expect("hello.widgets should be an array");
assert!(widgets.iter().any(|name| name.as_str() == Some("button")));
assert!(widgets.iter().any(|name| name.as_str() == Some("text")));
assert_eq!(
hello["mode"], "mock",
"hello.mode should be \"mock\" for --mock flag"
);
let version = hello["version"]
.as_str()
.expect("hello.version should be a string");
assert!(
!version.is_empty(),
"hello.version should be a non-empty string"
);
assert_eq!(
hello["name"], "plushie-renderer",
"hello.name should be \"plushie-renderer\""
);
drop(stdin);
child.wait().unwrap();
}
#[test]
fn single_session_echoes_session_id() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "test_1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let _hello = stdout.recv();
send(
&mut stdin,
&serde_json::json!({"session": "test_1", "type": "reset", "id": "r1"}),
);
let resp = stdout.recv();
assert_eq!(resp["type"], "reset_response");
assert_eq!(resp["session"], "test_1");
assert_eq!(resp["id"], "r1");
drop(stdin);
child.wait().unwrap();
}
#[test]
fn multiplexed_sessions_are_isolated() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--max-sessions", "4", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let _hello = stdout.recv();
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {"id": "root", "type": "text", "props": {"content": "session one"}, "children": []}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s2",
"type": "snapshot",
"tree": {"id": "root", "type": "text", "props": {"content": "session two"}, "children": []}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "query",
"id": "q1",
"target": "tree",
"selector": {}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s2",
"type": "query",
"id": "q2",
"target": "tree",
"selector": {}
}),
);
let r1 = stdout.recv();
let r2 = stdout.recv();
let mut responses: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
responses.insert(r1["session"].as_str().unwrap().to_string(), r1);
responses.insert(r2["session"].as_str().unwrap().to_string(), r2);
let s1_tree = &responses["s1"];
assert_eq!(s1_tree["type"], "query_response");
assert_eq!(s1_tree["id"], "q1");
assert_eq!(s1_tree["data"]["props"]["content"], "session one");
let s2_tree = &responses["s2"];
assert_eq!(s2_tree["type"], "query_response");
assert_eq!(s2_tree["id"], "q2");
assert_eq!(s2_tree["data"]["props"]["content"], "session two");
drop(stdin);
child.wait().unwrap();
}
#[test]
fn reset_tears_down_session() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--max-sessions", "4", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let _hello = stdout.recv();
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {"id": "root", "type": "text", "props": {"content": "before"}, "children": []}
}),
);
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "reset", "id": "r1"}),
);
let first = stdout.recv();
let second = stdout.recv();
let messages = [first, second];
let reset_response = messages
.iter()
.find(|m| m["type"] == "reset_response")
.unwrap_or_else(|| panic!("expected a reset_response, got: {messages:?}"));
assert_eq!(reset_response["id"], "r1");
let closed_event = messages
.iter()
.find(|m| m["type"] == "event" && m["family"] == "session_closed")
.unwrap_or_else(|| panic!("expected a session_closed event, got: {messages:?}"));
assert_eq!(closed_event["session"], "s1");
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "query",
"id": "q1",
"target": "tree",
"selector": {}
}),
);
let tree_resp = stdout.recv();
assert_eq!(tree_resp["session"], "s1");
assert!(tree_resp["data"].is_null());
drop(stdin);
child.wait().unwrap();
}
#[cfg(not(target_os = "windows"))]
#[test]
fn headless_interact_step_round_trip() {
let (mut child, _stderr) = spawn_renderer(&["--headless", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let _hello = stdout.recv();
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "btn1", "type": "button", "props": {"label": "Click me"}, "children": []}
]}
]
}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "interact",
"id": "i1",
"action": "click",
"selector": {"by": "id", "value": "btn1"},
"payload": {}
}),
);
let resp = stdout.recv_skip_status();
assert_eq!(resp["type"], "interact_response");
assert_eq!(resp["session"], "s1");
assert_eq!(resp["id"], "i1");
assert!(resp["events"].is_array());
let events = resp["events"].as_array().unwrap();
assert!(!events.is_empty(), "response should have events");
assert_eq!(events[0]["family"], "click");
assert_eq!(events[0]["id"], "btn1");
drop(stdin);
child.wait().unwrap();
}
#[test]
fn headless_advance_frame_emits_timestamp_object() {
let (mut child, _stderr) = spawn_renderer(&["--headless", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let stdout = LineReceiver::new(child.stdout.take().unwrap());
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let _hello = stdout.recv();
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "subscribe",
"kind": "on_animation_frame",
"tag": "anim",
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "advance_frame",
"timestamp": 16_000,
}),
);
let msg = stdout.recv_skip_status();
assert_eq!(msg["type"], "event");
assert_eq!(msg["session"], "s1");
assert_eq!(msg["family"], "animation_frame");
assert_eq!(msg["tag"], "anim");
assert_eq!(msg["value"], serde_json::json!({"timestamp": 16_000}));
drop(stdin);
child.wait().unwrap();
}
#[test]
fn text_input_emits_input_event_in_both_modes() {
#[cfg(target_os = "windows")]
let modes: &[Mode] = &[Mode::Mock];
#[cfg(not(target_os = "windows"))]
let modes: &[Mode] = &[Mode::Mock, Mode::Headless];
run_in_modes(modes, |mode| {
let (mut child, _stderr) = spawn_renderer(&[mode.flag(), "--json"]);
let mut stdin = child.stdin.take().unwrap();
let receiver = LineReceiver::new(child.stdout.take().unwrap());
let timeout = Duration::from_secs(10);
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = receiver.recv_timeout(timeout);
assert_eq!(hello["type"], "hello");
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "inp1", "type": "text_input", "props": {"value": "", "placeholder": "Type here"}, "children": []}
]}
]
}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "interact",
"id": "i1",
"action": "type_text",
"selector": {"by": "id", "value": "inp1"},
"payload": {"text": "hello"}
}),
);
let mut all_events = Vec::new();
loop {
let msg = receiver.recv_skip_status();
if msg["type"] == "interact_step" {
assert_eq!(msg["session"], "s1");
if let Some(evts) = msg["events"].as_array() {
all_events.extend(evts.clone());
}
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "inp1", "type": "text_input", "props": {"value": "hello"}, "children": []}
]}
]
}
}),
);
} else if msg["type"] == "interact_response" {
assert_eq!(msg["session"], "s1");
assert_eq!(msg["id"], "i1");
if let Some(evts) = msg["events"].as_array() {
all_events.extend(evts.clone());
}
break;
} else {
panic!("unexpected message type: {}", msg["type"]);
}
}
let input_event = all_events
.iter()
.find(|e| e["family"] == "input")
.unwrap_or_else(|| panic!("expected an input event in: {all_events:?}"));
assert_eq!(input_event["id"], "inp1");
assert_eq!(input_event["value"], "hello");
drop(stdin);
child.wait().unwrap();
});
}
#[test]
fn checkbox_emits_toggle_event_in_both_modes() {
#[cfg(target_os = "windows")]
let modes: &[Mode] = &[Mode::Mock];
#[cfg(not(target_os = "windows"))]
let modes: &[Mode] = &[Mode::Mock, Mode::Headless];
run_in_modes(modes, |mode| {
let (mut child, _stderr) = spawn_renderer(&[mode.flag(), "--json"]);
let mut stdin = child.stdin.take().unwrap();
let receiver = LineReceiver::new(child.stdout.take().unwrap());
let timeout = Duration::from_secs(10);
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = receiver.recv_timeout(timeout);
assert_eq!(hello["type"], "hello");
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "chk1", "type": "checkbox", "props": {"label": "Accept", "checked": false}, "children": []}
]}
]
}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "interact",
"id": "i1",
"action": "toggle",
"selector": {"by": "id", "value": "chk1"},
"payload": {"value": true}
}),
);
let mut toggle_events = Vec::new();
loop {
let msg = receiver.recv_timeout(timeout);
if msg["type"] == "interact_step" {
assert_eq!(msg["session"], "s1");
assert_eq!(msg["id"], "i1");
if let Some(evts) = msg["events"].as_array() {
toggle_events.extend(evts.clone());
}
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "chk1", "type": "checkbox", "props": {"label": "Accept", "checked": true}, "children": []}
]}
]
}
}),
);
} else if msg["type"] == "interact_response" {
assert_eq!(msg["session"], "s1");
assert_eq!(msg["id"], "i1");
if let Some(evts) = msg["events"].as_array() {
toggle_events.extend(evts.clone());
}
break;
} else if msg.get("family").and_then(|f| f.as_str()) == Some("status") {
continue;
} else {
panic!("unexpected message type: {}", msg["type"]);
}
}
assert!(
toggle_events.iter().any(|e| e["family"] == "toggle"),
"expected a toggle event in the interact flow, got: {toggle_events:?}"
);
drop(stdin);
child.wait().unwrap();
});
}
#[test]
fn slider_emits_slide_event_in_both_modes() {
run_in_both_modes(|mode| {
let (mut child, _stderr) = spawn_renderer(&[mode.flag(), "--json"]);
let mut stdin = child.stdin.take().unwrap();
let receiver = LineReceiver::new(child.stdout.take().unwrap());
let timeout = Duration::from_secs(10);
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = receiver.recv_timeout(timeout);
assert_eq!(hello["type"], "hello");
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "snapshot",
"tree": {
"id": "main", "type": "window", "props": {}, "children": [
{"id": "root", "type": "column", "props": {}, "children": [
{"id": "sld1", "type": "slider", "props": {"value": 50, "range": [0, 100]}, "children": []}
]}
]
}
}),
);
send(
&mut stdin,
&serde_json::json!({
"session": "s1",
"type": "interact",
"id": "i1",
"action": "slide",
"selector": {"by": "id", "value": "sld1"},
"payload": {"value": 75.0}
}),
);
let resp = receiver.recv_skip_status();
assert_eq!(resp["type"], "interact_response");
assert_eq!(resp["session"], "s1");
assert_eq!(resp["id"], "i1");
let events = resp["events"]
.as_array()
.expect("events should be an array");
let slide_event = events
.iter()
.find(|e| e["family"] == "slide")
.expect("expected a slide event");
assert_eq!(slide_event["id"], "sld1");
assert_eq!(events[0]["value"], 75.0);
drop(stdin);
child.wait().unwrap();
});
}
#[test]
fn concurrent_sessions_interleaved() {
let (mut child, _stderr) = spawn_renderer(&["--mock", "--max-sessions", "4", "--json"]);
let mut stdin = child.stdin.take().unwrap();
let receiver = LineReceiver::new(child.stdout.take().unwrap());
let timeout = Duration::from_secs(10);
send(
&mut stdin,
&serde_json::json!({"session": "s1", "type": "settings", "settings": {"protocol_version": 1}}),
);
let hello = receiver.recv_timeout(timeout);
assert_eq!(hello["type"], "hello");
let session_ids = ["s1", "s2", "s3", "s4"];
for &sid in &session_ids {
send(
&mut stdin,
&serde_json::json!({
"session": sid,
"type": "snapshot",
"tree": {
"id": "root",
"type": "text",
"props": {"content": format!("content-{sid}")},
"children": []
}
}),
);
}
for (i, &sid) in session_ids.iter().enumerate() {
send(
&mut stdin,
&serde_json::json!({
"session": sid,
"type": "query",
"id": format!("q{}", i + 1),
"target": "tree",
"selector": {}
}),
);
}
let mut responses: std::collections::HashMap<String, serde_json::Value> =
std::collections::HashMap::new();
for _ in 0..4 {
let resp = receiver.recv_timeout(timeout);
let session = resp["session"].as_str().unwrap().to_string();
responses.insert(session, resp);
}
for &sid in &session_ids {
let resp = responses
.get(sid)
.unwrap_or_else(|| panic!("missing response for session {sid}"));
assert_eq!(resp["type"], "query_response");
assert_eq!(
resp["data"]["props"]["content"],
format!("content-{sid}"),
"session {sid} should have its own tree content"
);
}
drop(stdin);
child.wait().unwrap();
}