use anyhow::{Context, Result};
use crossterm::{
cursor,
event::{poll, read, Event as CtEvent, KeyCode, KeyEventKind, KeyModifiers},
terminal::{disable_raw_mode, enable_raw_mode},
ExecutableCommand,
};
use ratatui::backend::CrosstermBackend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Paragraph, Widget, Wrap};
use ratatui::{Terminal, TerminalOptions, Viewport};
use std::io::Write;
use std::os::raw::c_int;
use std::sync::mpsc::{channel, Receiver, Sender, TryRecvError};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
const LIVE_HEIGHT: u16 = 6;
const SPINNER_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
#[derive(Debug, Clone)]
pub enum AppPhase {
Setup,
Initializing,
Building {
started_at: Instant,
kind: BuildKind,
},
Idle,
Patching { started_at: Instant },
Failed { phase: String, reason: String },
}
#[derive(Debug, Clone, Copy)]
pub enum BuildKind {
Initial,
Rebuild,
}
#[derive(Debug, Clone, Copy)]
pub enum StepStatus {
Done,
Failed,
Skipped,
}
#[derive(Debug, Clone)]
pub struct LiveState {
pub target: String,
pub bundle: String,
pub phase: AppPhase,
pub current_step: Option<String>,
pub ws_addr: Option<String>,
pub watching: Vec<String>,
pub client_count: usize,
pub last_build: Option<String>,
pub last_patch: Option<String>,
pub should_quit: bool,
pub user_initiated_quit: bool,
}
impl LiveState {
pub fn new(target: impl Into<String>, bundle: impl Into<String>) -> Self {
Self {
target: target.into(),
bundle: bundle.into(),
phase: AppPhase::Setup,
current_step: None,
ws_addr: None,
watching: Vec::new(),
client_count: 0,
last_build: None,
last_patch: None,
should_quit: false,
user_initiated_quit: false,
}
}
}
#[derive(Debug, Clone)]
pub enum HistoryItem {
PhaseEnter(String),
PhaseDone {
label: String,
status: StepStatus,
elapsed: Duration,
},
Step {
label: String,
status: StepStatus,
elapsed: Duration,
},
CapturedStderr(String),
DeviceLog { stream: String, line: String },
Failure(String),
SetCurrentStep(Option<String>),
}
pub fn apply_event(
state: &mut LiveState,
event: &whisker_dev_server::Event,
history: &mut Vec<HistoryItem>,
) {
use whisker_dev_server::Event;
match event {
Event::Started => {
}
Event::BuildingFull => {
let kind = match state.phase {
AppPhase::Setup | AppPhase::Initializing => BuildKind::Initial,
_ => BuildKind::Rebuild,
};
state.phase = AppPhase::Building {
started_at: Instant::now(),
kind,
};
state.current_step = None;
}
Event::BuildSucceeded => {
if let AppPhase::Building { started_at, kind } = &state.phase {
let elapsed = started_at.elapsed();
state.last_build = Some(format!(
"{} · {}",
if matches!(kind, BuildKind::Initial) {
"initial"
} else {
"rebuild"
},
fmt_elapsed(elapsed)
));
}
state.phase = AppPhase::Idle;
state.current_step = None;
}
Event::BuildFailed(msg) => {
let phase = "build".to_string();
history.push(HistoryItem::PhaseDone {
label: phase.clone(),
status: StepStatus::Failed,
elapsed: Duration::ZERO,
});
history.push(HistoryItem::Failure(msg.clone()));
state.phase = AppPhase::Failed {
phase,
reason: msg.clone(),
};
state.current_step = None;
}
Event::ClientConnected => {
state.client_count = state.client_count.saturating_add(1);
}
Event::ClientDisconnected => {
state.client_count = state.client_count.saturating_sub(1);
}
Event::PatchBuilding => {
state.phase = AppPhase::Patching {
started_at: Instant::now(),
};
}
Event::PatchSent => {
if let AppPhase::Patching { started_at } = &state.phase {
let elapsed = started_at.elapsed();
state.last_patch = Some(fmt_elapsed(elapsed));
}
state.phase = AppPhase::Idle;
state.current_step = None;
}
Event::DeviceLog { stream, line, .. } => {
history.push(HistoryItem::DeviceLog {
stream: stream.clone(),
line: line.clone(),
});
}
}
}
#[derive(Clone)]
pub struct TuiHandle {
live: Arc<Mutex<LiveState>>,
tx: Sender<HistoryItem>,
}
impl TuiHandle {
fn with<F: FnOnce(&mut LiveState)>(&self, f: F) {
if let Ok(mut g) = self.live.lock() {
f(&mut g);
}
}
fn send(&self, item: HistoryItem) {
let _ = self.tx.send(item);
}
pub fn set_phase(&self, phase: AppPhase) {
self.with(|s| {
s.phase = phase;
s.current_step = None;
});
}
pub fn start_step(&self, label: impl Into<String>) {
let label = label.into();
self.with(|s| {
s.current_step = Some(label);
});
}
pub fn finish_step(&self, label: impl Into<String>, status: StepStatus, elapsed: Duration) {
let label = label.into();
self.with(|s| s.current_step = None);
self.send(HistoryItem::Step {
label,
status,
elapsed,
});
}
pub fn apply_event(&self, event: &whisker_dev_server::Event) {
let mut history: Vec<HistoryItem> = Vec::new();
self.with(|s| apply_event(s, event, &mut history));
for h in history {
self.send(h);
}
}
pub fn set_dev_server(&self, ws_addr: impl Into<String>, watching: Vec<String>) {
let ws_addr = ws_addr.into();
self.with(|s| {
s.ws_addr = Some(ws_addr);
s.watching = watching;
});
}
pub fn should_quit(&self) -> bool {
self.live.lock().map(|s| s.should_quit).unwrap_or(false)
}
pub fn request_quit(&self) {
self.with(|s| s.should_quit = true);
}
#[cfg(test)]
pub fn snapshot(&self) -> LiveState {
self.live.lock().unwrap().clone()
}
}
struct OriginalStderr(c_int);
impl Write for OriginalStderr {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
let n = unsafe { libc::write(self.0, buf.as_ptr() as *const _, buf.len()) };
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
pub struct Tui {
terminal: Terminal<CrosstermBackend<OriginalStderr>>,
live: Arc<Mutex<LiveState>>,
rx: Receiver<HistoryItem>,
saved_stderr_fd: c_int,
spinner_idx: usize,
}
impl Tui {
pub fn start(target: String, bundle: String) -> Result<(Self, TuiHandle)> {
let (saved_stderr_fd, capture_read_fd) =
install_stderr_capture().context("install stderr capture")?;
install_terminal_cleanup_once(saved_stderr_fd);
enable_raw_mode().context("enable raw mode")?;
let mut original = OriginalStderr(saved_stderr_fd);
original.execute(cursor::Hide).context("hide cursor")?;
let backend = CrosstermBackend::new(original);
let terminal = Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(LIVE_HEIGHT),
},
)
.context("create ratatui terminal (inline viewport)")?;
let live = Arc::new(Mutex::new(LiveState::new(target, bundle)));
let (tx, rx) = channel::<HistoryItem>();
{
let tx = tx.clone();
std::thread::Builder::new()
.name("whisker-tui-stderr-capture".into())
.spawn(move || capture_reader_loop(capture_read_fd, tx))
.context("spawn stderr capture reader")?;
}
let handle = TuiHandle {
live: Arc::clone(&live),
tx,
};
Ok((
Self {
terminal,
live,
rx,
saved_stderr_fd,
spinner_idx: 0,
},
handle,
))
}
pub fn render_until_quit(&mut self) -> Result<()> {
let frame_interval = Duration::from_millis(100);
let mut last_draw = Instant::now() - frame_interval;
loop {
self.drain_history_into_scrollback()?;
if last_draw.elapsed() >= frame_interval {
self.spinner_idx = self.spinner_idx.wrapping_add(1);
let snapshot = self.live.lock().ok().map(|g| g.clone());
if let Some(s) = snapshot {
let spinner_idx = self.spinner_idx;
self.terminal
.draw(|f| render_live(f, &s, spinner_idx))
.context("draw live region")?;
}
last_draw = Instant::now();
}
if poll(Duration::from_millis(50))? {
if let CtEvent::Key(key) = read()? {
if matches!(key.kind, KeyEventKind::Press) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => self.user_quit(),
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.user_quit()
}
_ => {}
}
}
}
}
if let Ok(s) = self.live.lock() {
if s.should_quit {
break;
}
}
}
Ok(())
}
fn drain_history_into_scrollback(&mut self) -> Result<()> {
loop {
match self.rx.try_recv() {
Ok(HistoryItem::SetCurrentStep(label)) => {
if let Ok(mut s) = self.live.lock() {
s.current_step = label;
}
}
Ok(item) => {
let lines = render_history_item(&item);
let height = lines.len().min(u16::MAX as usize) as u16;
if height == 0 {
continue;
}
self.terminal
.insert_before(height, move |buf| {
write_lines_to_buffer(buf, &lines);
})
.context("insert history line into scrollback")?;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => break,
}
}
Ok(())
}
fn user_quit(&self) {
if let Ok(mut s) = self.live.lock() {
s.should_quit = true;
s.user_initiated_quit = true;
}
}
pub fn was_user_quit(&self) -> bool {
self.live
.lock()
.map(|s| s.user_initiated_quit)
.unwrap_or(false)
}
pub fn shutdown(mut self) -> Result<()> {
let _ = self.drain_history_into_scrollback();
let _ = self.terminal.clear();
let _ = self.terminal.show_cursor();
let mut original = OriginalStderr(self.saved_stderr_fd);
let _ = original.write_all(b"\r\n");
let _ = disable_raw_mode();
unsafe {
libc::dup2(self.saved_stderr_fd, libc::STDERR_FILENO);
libc::close(self.saved_stderr_fd);
}
Ok(())
}
}
fn install_stderr_capture() -> Result<(c_int, c_int)> {
let mut fds: [c_int; 2] = [-1, -1];
let rc = unsafe { libc::pipe(fds.as_mut_ptr()) };
if rc != 0 {
return Err(std::io::Error::last_os_error()).context("pipe(2)");
}
let read_fd = fds[0];
let write_fd = fds[1];
let saved_fd = unsafe { libc::dup(libc::STDERR_FILENO) };
if saved_fd == -1 {
let e = std::io::Error::last_os_error();
unsafe {
libc::close(read_fd);
libc::close(write_fd);
}
return Err(e).context("dup STDERR_FILENO");
}
if unsafe { libc::dup2(write_fd, libc::STDERR_FILENO) } == -1 {
let e = std::io::Error::last_os_error();
unsafe {
libc::close(read_fd);
libc::close(write_fd);
libc::close(saved_fd);
}
return Err(e).context("dup2 over STDERR_FILENO");
}
unsafe {
libc::close(write_fd);
}
Ok((saved_fd, read_fd))
}
fn capture_reader_loop(read_fd: c_int, tx: Sender<HistoryItem>) {
let mut buf = [0u8; 4096];
let mut partial: Vec<u8> = Vec::new();
loop {
let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut _, buf.len()) };
if n == -1 {
if std::io::Error::last_os_error().raw_os_error() == Some(libc::EINTR) {
continue;
}
return;
}
if n == 0 {
return;
}
let chunk = &buf[..n as usize];
partial.extend_from_slice(chunk);
while let Some(nl_pos) = partial.iter().position(|b| *b == b'\n') {
let mut line: Vec<u8> = partial.drain(..=nl_pos).collect();
while matches!(line.last(), Some(b'\n') | Some(b'\r')) {
line.pop();
}
let text = match String::from_utf8(line) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(&e.into_bytes()).into_owned(),
};
if let Some(rest) = text.strip_prefix("\x1eWHISKER-TUI-STEP-START\x1e") {
let label = rest.replace('\x1e', " ").trim().to_string();
if !label.is_empty() && tx.send(HistoryItem::SetCurrentStep(Some(label))).is_err() {
return;
}
continue;
}
if text == "\x1eWHISKER-TUI-STEP-END" {
if tx.send(HistoryItem::SetCurrentStep(None)).is_err() {
return;
}
continue;
}
let text = strip_ansi(&text);
if !text.is_empty() && tx.send(HistoryItem::CapturedStderr(text)).is_err() {
return;
}
}
}
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut iter = s.chars().peekable();
while let Some(c) = iter.next() {
if c == '\x1b' {
match iter.peek().copied() {
Some('[') => {
iter.next();
for ch in iter.by_ref() {
if matches!(ch as u32, 0x40..=0x7e) {
break;
}
}
}
Some(']') => {
iter.next();
while let Some(ch) = iter.next() {
if ch == '\x07' {
break;
}
if ch == '\x1b' {
if matches!(iter.peek(), Some('\\')) {
iter.next();
}
break;
}
}
}
_ => {
}
}
continue;
}
if c == '\t' || (c as u32) >= 0x20 {
out.push(c);
}
}
out
}
fn emergency_terminal_reset(original_stderr_fd: c_int) {
let mut o = OriginalStderr(original_stderr_fd);
let _ = o.execute(cursor::Show);
let _ = o.write_all(b"\r\n");
let _ = disable_raw_mode();
}
fn install_terminal_cleanup_once(original_stderr_fd: c_int) {
use std::sync::atomic::{AtomicBool, AtomicI32, Ordering};
static INSTALLED: AtomicBool = AtomicBool::new(false);
static SAVED_FD: AtomicI32 = AtomicI32::new(-1);
SAVED_FD.store(original_stderr_fd, Ordering::Release);
if INSTALLED.swap(true, Ordering::AcqRel) {
return;
}
let prev_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
emergency_terminal_reset(SAVED_FD.load(Ordering::Acquire));
prev_hook(info);
}));
let _ = ctrlc::set_handler(|| {
emergency_terminal_reset(SAVED_FD.load(Ordering::Acquire));
whisker_build::child_guard::kill_all();
std::process::exit(130);
});
}
fn render_live(frame: &mut ratatui::Frame, state: &LiveState, spinner_idx: usize) {
let area = frame.area();
let lines = build_live_lines(state, spinner_idx);
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area);
}
fn build_live_lines(state: &LiveState, spinner_idx: usize) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let (chip_label, chip_bg, chip_fg) = status_chip(state);
let mut header: Vec<Span<'static>> = vec![
Span::styled(
format!(" {chip_label} "),
Style::default()
.fg(chip_fg)
.bg(chip_bg)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
state.target.clone(),
Style::default().add_modifier(Modifier::BOLD),
),
Span::styled(" · ", Style::default().fg(Color::DarkGray)),
Span::raw(state.bundle.clone()),
];
if let Some(extra) = phase_elapsed(&state.phase) {
header.push(Span::styled(" · ", Style::default().fg(Color::DarkGray)));
header.push(Span::styled(extra, Style::default().fg(Color::DarkGray)));
}
lines.push(Line::from(header));
match (&state.current_step, &state.phase) {
(Some(label), _) => {
let spinner = SPINNER_FRAMES[spinner_idx % SPINNER_FRAMES.len()];
let (_, chip_bg, _) = status_chip(state);
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(spinner.to_string(), Style::default().fg(chip_bg)),
Span::raw(" "),
Span::raw(label.clone()),
]));
}
(None, AppPhase::Failed { reason, .. }) => {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(reason.clone(), Style::default().fg(Color::Red)),
]));
}
(None, _) => {
lines.push(Line::from(""));
}
}
if let Some(addr) = &state.ws_addr {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("dev server ", Style::default().fg(Color::DarkGray)),
Span::raw(format!("ws://{addr}")),
]));
let clients = format!("{} connected", state.client_count);
let mut watching = vec![
Span::raw(" "),
Span::styled("clients ", Style::default().fg(Color::DarkGray)),
Span::raw(clients),
];
if !state.watching.is_empty() {
watching.push(Span::styled(
" · ",
Style::default().fg(Color::DarkGray),
));
watching.push(Span::styled(
format!("watching {} path(s)", state.watching.len()),
Style::default().fg(Color::DarkGray),
));
}
lines.push(Line::from(watching));
} else {
lines.push(Line::from(""));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
" q ",
Style::default()
.fg(Color::White)
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
),
Span::styled(" quit", Style::default().fg(Color::DarkGray)),
]));
lines.truncate(LIVE_HEIGHT as usize);
while lines.len() < LIVE_HEIGHT as usize {
lines.push(Line::from(""));
}
lines
}
fn render_history_item(item: &HistoryItem) -> Vec<Line<'static>> {
match item {
HistoryItem::PhaseEnter(label) => vec![Line::from(vec![
Span::styled("▶ ", Style::default().fg(Color::Cyan)),
Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
])],
HistoryItem::PhaseDone {
label,
status,
elapsed,
} => {
let (glyph, color) = match status {
StepStatus::Done => ("✓ ", Color::Green),
StepStatus::Failed => ("✗ ", Color::Red),
StepStatus::Skipped => ("○ ", Color::DarkGray),
};
vec![Line::from(vec![
Span::styled(glyph, Style::default().fg(color)),
Span::styled(label.clone(), Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" "),
Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
])]
}
HistoryItem::Step {
label,
status,
elapsed,
} => {
let (glyph, color) = match status {
StepStatus::Done => ("✓", Color::Green),
StepStatus::Failed => ("✗", Color::Red),
StepStatus::Skipped => ("○", Color::DarkGray),
};
vec![Line::from(vec![
Span::raw(" "),
Span::styled(glyph, Style::default().fg(color)),
Span::raw(" "),
Span::raw(label.clone()),
Span::raw(" "),
Span::styled(fmt_elapsed(*elapsed), Style::default().fg(Color::DarkGray)),
])]
}
HistoryItem::CapturedStderr(text) => {
vec![Line::from(Span::raw(text.clone()))]
}
HistoryItem::DeviceLog { stream, line } => {
let tag = match stream.as_str() {
"stderr" => "[device:err]",
_ => "[device]",
};
vec![Line::from(vec![
Span::styled(tag, Style::default().fg(Color::Magenta)),
Span::raw(" "),
Span::raw(line.clone()),
])]
}
HistoryItem::Failure(reason) => vec![Line::from(vec![
Span::styled(
"✗ ",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
Span::styled(reason.clone(), Style::default().fg(Color::Red)),
])],
HistoryItem::SetCurrentStep(_) => {
Vec::new()
}
}
}
fn write_lines_to_buffer(buf: &mut Buffer, lines: &[Line<'static>]) {
for (i, line) in lines.iter().enumerate() {
let area = Rect {
x: buf.area.x,
y: buf.area.y + i as u16,
width: buf.area.width,
height: 1,
};
if area.y >= buf.area.bottom() {
break;
}
Paragraph::new(line.clone()).render(area, buf);
}
}
fn status_chip(state: &LiveState) -> (&'static str, Color, Color) {
if matches!(state.phase, AppPhase::Failed { .. }) {
return ("FAILED", Color::Red, Color::White);
}
if matches!(state.phase, AppPhase::Patching { .. }) {
return ("PATCHING", Color::Magenta, Color::Black);
}
if matches!(state.phase, AppPhase::Building { .. }) {
return ("BUILDING", Color::Yellow, Color::Black);
}
if matches!(state.phase, AppPhase::Idle) {
if state.current_step.is_some() {
return ("BUILDING", Color::Yellow, Color::Black);
}
return ("RUNNING", Color::Green, Color::Black);
}
("STARTING", Color::DarkGray, Color::White)
}
fn phase_elapsed(phase: &AppPhase) -> Option<String> {
match phase {
AppPhase::Building { started_at, .. } | AppPhase::Patching { started_at } => {
Some(fmt_elapsed(started_at.elapsed()))
}
_ => None,
}
}
fn fmt_elapsed(d: Duration) -> String {
let ms = d.as_millis();
if ms < 1_000 {
format!("{ms}ms")
} else if ms < 60_000 {
format!("{:.1}s", ms as f64 / 1_000.0)
} else {
let secs = ms / 1_000;
format!("{}m{:02}s", secs / 60, secs % 60)
}
}
#[cfg(test)]
mod tests {
use super::*;
use whisker_dev_server::Event;
fn s() -> LiveState {
LiveState::new("iOS Simulator", "rs.whisker.podcast")
}
fn drain(state: &mut LiveState, e: &Event) -> Vec<HistoryItem> {
let mut h = Vec::new();
apply_event(state, e, &mut h);
h
}
#[test]
fn build_lifecycle_records_outcome() {
let mut st = s();
let started = drain(&mut st, &Event::BuildingFull);
assert!(matches!(st.phase, AppPhase::Building { .. }));
assert!(started.is_empty());
let done = drain(&mut st, &Event::BuildSucceeded);
assert!(matches!(st.phase, AppPhase::Idle));
assert!(st.last_build.is_some());
assert!(done.is_empty());
}
#[test]
fn client_counter_saturates() {
let mut st = s();
drain(&mut st, &Event::ClientConnected);
drain(&mut st, &Event::ClientConnected);
assert_eq!(st.client_count, 2);
drain(&mut st, &Event::ClientDisconnected);
drain(&mut st, &Event::ClientDisconnected);
drain(&mut st, &Event::ClientDisconnected);
assert_eq!(st.client_count, 0);
}
#[test]
fn device_log_becomes_history_item() {
let mut st = s();
let h = drain(
&mut st,
&Event::DeviceLog {
stream: "stdout".into(),
line: "hello".into(),
ts_micros: 0,
},
);
assert_eq!(h.len(), 1);
match &h[0] {
HistoryItem::DeviceLog { stream, line } => {
assert_eq!(stream, "stdout");
assert_eq!(line, "hello");
}
other => panic!("expected DeviceLog, got {other:?}"),
}
}
#[test]
fn patch_sent_records_elapsed_and_resets_phase() {
let mut st = s();
st.phase = AppPhase::Patching {
started_at: Instant::now() - Duration::from_millis(615),
};
let h = drain(&mut st, &Event::PatchSent);
assert!(matches!(st.phase, AppPhase::Idle));
assert!(st.last_patch.is_some());
assert!(h.is_empty());
}
#[test]
fn patch_building_transitions_phase_to_patching() {
let mut st = s();
st.phase = AppPhase::Idle;
let h = drain(&mut st, &Event::PatchBuilding);
assert!(
matches!(st.phase, AppPhase::Patching { .. }),
"phase should be Patching after PatchBuilding"
);
assert!(h.is_empty(), "PatchBuilding shouldn't emit history rows");
}
#[test]
fn build_failed_emits_failure_history() {
let mut st = s();
drain(&mut st, &Event::BuildingFull);
let h = drain(&mut st, &Event::BuildFailed("link error".into()));
assert!(matches!(st.phase, AppPhase::Failed { .. }));
assert!(h.iter().any(|i| matches!(i, HistoryItem::Failure(_))));
}
#[test]
fn strip_ansi_removes_csi_sgr() {
let s = "\x1b[33mwarning\x1b[0m: \x1b[1munused\x1b[0m";
assert_eq!(strip_ansi(s), "warning: unused");
}
#[test]
fn strip_ansi_preserves_utf8_glyphs() {
let s = "\x1b[32m✓\x1b[0m Sync gen/ios";
assert_eq!(strip_ansi(s), "✓ Sync gen/ios");
}
#[test]
fn strip_ansi_drops_osc_titles() {
let s = "\x1b]0;title\x07hello";
assert_eq!(strip_ansi(s), "hello");
}
#[test]
fn build_live_lines_has_fixed_height() {
let st = s();
let lines = build_live_lines(&st, 0);
assert_eq!(lines.len(), LIVE_HEIGHT as usize);
}
#[test]
fn build_live_lines_shows_current_step() {
let mut st = s();
st.current_step = Some("xcodebuild WhiskerDriver-Debug".into());
let lines = build_live_lines(&st, 0);
let rendered = lines
.iter()
.flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
.collect::<Vec<_>>()
.join("");
assert!(rendered.contains("xcodebuild"));
}
#[test]
fn build_live_lines_shows_dev_server_when_set() {
let mut st = s();
st.ws_addr = Some("127.0.0.1:9090".into());
st.client_count = 1;
let lines = build_live_lines(&st, 0);
let rendered = lines
.iter()
.flat_map(|l| l.spans.iter().map(|sp| sp.content.to_string()))
.collect::<Vec<_>>()
.join("");
assert!(rendered.contains("127.0.0.1:9090"));
assert!(rendered.contains("1 connected"));
}
}