Log tabs with a daemon I/O view, and Playwright UI tests

The log view splits into All / Daemon I/O / Scans / HTTP. Daemon I/O is
the control protocol itself, logged where every command funnels through
so it covers socket clients, the CLI and the web UI alike. stderr and the
in-app buffer now have separate filters, so the UI can keep debug detail
the terminal should not carry.

Playwright drives a real browser against a daemon on fixture feeds. Eight
tests, each mapping to a bug that reached a user -- the Rust tests and the
stub-DOM smoke test cannot see a wrong selector or a dead handler.

It immediately found one: OPML folders rendered expanded by default,
because the code stored closed groups, so any folder never toggled counted
as open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
2026-09-10 17:07:19 +00:00
parent 4429f1119c
commit 8b21d19365
18 changed files with 457 additions and 20 deletions

View File

@@ -149,6 +149,15 @@ impl Emitter {
}
if let Some(tx) = &self.tx {
// The outbound half of the protocol, as it goes on the wire. Progress is the
// high-volume one, so it sits at debug.
if let Ok(json) = serde_json::to_string(&e) {
if matches!(e, Event::Progress { .. }) {
tracing::debug!(target: "ipx::io", "<- {json}");
} else {
tracing::info!(target: "ipx::io", "<- {json}");
}
}
// An error here only means nobody is listening yet.
let _ = tx.send(e.clone());
}

View File

@@ -10,7 +10,7 @@ use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
/// Kept small enough to be cheap to hold and to serialise in one response.
const CAPACITY: usize = 2000;
const CAPACITY: usize = 5000;
#[derive(Clone, Debug, serde::Serialize)]
pub struct LogLine {

View File

@@ -126,13 +126,21 @@ async fn main() -> Result<()> {
{
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::Layer;
// Two filters, deliberately different. stderr follows IPX_LOG; the in-app buffer
// keeps debug as well, so the log view can show protocol traffic and routine
// skips that would be noise on a terminal. IPX_UI_LOG overrides it.
let stderr_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_LOG")
.unwrap_or_else(|_| "ipx=info".into());
let ui_filter = tracing_subscriber::EnvFilter::try_from_env("IPX_UI_LOG")
.unwrap_or_else(|_| "ipx=debug".into());
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_env("IPX_LOG")
.unwrap_or_else(|_| "ipx=info".into()),
tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_filter(stderr_filter),
)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(logbuf::RingLayer)
.with(logbuf::RingLayer.with_filter(ui_filter))
.init();
}
@@ -282,7 +290,14 @@ async fn daemon(
biased;
_ = stop.changed() => break,
Some(cmd) = rx_cmd.recv() => {
tracing::info!(?cmd, "command from a client");
// Both halves of the protocol are logged under one target so the UI can
// show the conversation on its own: this is everything arriving, whatever
// the source -- a socket client, the CLI proxying, or the web UI.
tracing::info!(
target: "ipx::io",
"-> {}",
serde_json::to_string(&cmd).unwrap_or_else(|_| format!("{cmd:?}"))
);
if !until_stopped(&ctx, &rx_stop, run(&ctx, cmd)).await {
break;
}