use super::cast::cast_player_page;
use super::client_js::live_client_js;
use super::escape::esc;
use super::session::session_page;
use super::session_export::session_export_page;
use crate::daemon::model::{DaemonMode, ProcKind, ProcRecord, ProcStatus, Session, Store};
fn store_with_cast_proc(status: ProcStatus) -> Store {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"castab".into(),
Session {
id: "castab".into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 1,
client_connected: true,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "claude: add".into(),
status,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: Some("/tmp/x.cast".into()),
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: None,
lines: vec![],
}],
workflow: None,
parent_session: None,
},
);
store
}
fn session_procs_html(html: &str) -> &str {
let needle = r#"<div class="procs" id="session-procs">"#;
let start = html.find(needle).expect("session-procs") + needle.len();
let tail = &html[start..];
let end = tail.find("<script").expect("script block after procs");
&tail[..end]
}
#[test]
fn esc_handles_basic_html() {
assert_eq!(esc("<a>"), "<a>");
}
#[test]
fn browser_player_is_first_party_and_carries_no_third_party_license() {
let js = super::PLAYER_JS;
let css = super::PLAYER_CSS;
assert!(js.contains("BeeCastPlayer"), "the first-party player global must be defined");
assert!(js.contains("BeeCastVT"), "the DOM-free core must be bundled first");
assert!(js.contains("Clean-room implementation"), "the clean-room statement rides in the asset");
for banned in ["asciinema-player", "AsciinemaPlayer", "@license", "Apache"] {
assert!(!js.contains(banned), "browser player JS must not carry '{banned}'");
assert!(!css.contains(banned), "browser player CSS must not carry '{banned}'");
}
}
#[test]
fn vt_core_node_selftest() {
if crate::runtime::which("node").is_none() {
return;
}
let dir = std::env::temp_dir().join(format!("scsh-vt-selftest-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bundle = dir.join("player.js");
std::fs::write(&bundle, super::PLAYER_JS).unwrap();
let script = format!(
r#"
const assert = require('assert');
require({bundle:?});
const VT = globalThis.BeeCastVT;
// v3: intervals sum; term size from header; resize + marker events survive; # comments skip.
let c = VT.parseCast('{{"version":3,"term":{{"cols":10,"rows":3}}}}\n# note\n[0.5,"o","hi"]\n[0.5,"m","chapter"]\n[1.0,"r","20x5"]\n');
assert.strictEqual(c.cols, 10); assert.strictEqual(c.rows, 3);
assert.strictEqual(c.events.length, 3);
assert.strictEqual(c.duration, 2);
assert.strictEqual(c.events[2].t, 2);
// v2: absolute times.
c = VT.parseCast('{{"version":2,"width":80,"height":24}}\n[0.5,"o","a"]\n[2.0,"o","b"]\n');
assert.strictEqual(c.duration, 2); assert.strictEqual(c.events[1].t, 2);
// v1: one JSON doc, stdout deltas.
c = VT.parseCast('{{"version":1,"width":5,"height":2,"stdout":[[0.1,"x"],[0.2,"y"]]}}');
assert.strictEqual(c.cols, 5); assert.strictEqual(c.events.length, 2);
assert(Math.abs(c.duration - 0.3) < 1e-9);
// Plain text + CR/LF.
let t = new VT.Term(10, 3);
t.write('hello\r\nworld');
assert.deepStrictEqual(t.textLines(), ['hello', 'world', '']);
// CUP + overwrite mid-screen.
t.write('\x1b[1;3Hga');
assert.strictEqual(t.textLines()[0], 'hegao');
// ED 2 clears everything.
t.write('\x1b[2J');
assert.deepStrictEqual(t.textLines(), ['', '', '']);
// SGR runs merge; colors land on cells.
t = new VT.Term(10, 1);
t.write('\x1b[31mred\x1b[0m ok');
const runs = t.snapshot().rows[0];
assert.strictEqual(runs[0].text, 'red'); assert.strictEqual(runs[0].fg, 1);
assert.strictEqual(runs[1].fg, null);
// 256-color + truecolor.
t = new VT.Term(4, 1);
t.write('\x1b[38;5;196mX\x1b[38;2;1;2;3mY');
const r2 = t.snapshot().rows[0];
assert.strictEqual(r2[0].fg, 196);
assert.strictEqual(r2[1].fg, '#010203');
assert.strictEqual(VT.color256(196), '#ff0000');
assert.strictEqual(VT.color256(232), '#080808');
// Deferred wrap: printing in the last column does not wrap until the next char.
t = new VT.Term(3, 2);
t.write('abc');
assert.strictEqual(t.snapshot().cursor.y, 0);
t.write('d');
assert.deepStrictEqual(t.textLines(), ['abc', 'd']);
// Scroll region: LF at the region bottom scrolls only the region.
t = new VT.Term(5, 4);
t.write('aa\r\nbb\r\ncc\r\ndd');
t.write('\x1b[2;3r\x1b[3;1H\n');
const lines = t.textLines();
assert.strictEqual(lines[0], 'aa');
assert.strictEqual(lines[1], 'cc');
assert.strictEqual(lines[3], 'dd');
// Alternate screen: primary content comes back on exit.
t = new VT.Term(5, 2);
t.write('main');
t.write('\x1b[?1049h\x1b[Halt');
assert.strictEqual(t.textLines()[0], 'alt');
t.write('\x1b[?1049l');
assert.strictEqual(t.textLines()[0], 'main');
// DEC special graphics: tmux border characters.
t = new VT.Term(4, 1);
t.write('\x1b(0qqx\x1b(B');
assert.strictEqual(t.textLines()[0], '──│');
// Cursor hide/show.
t = new VT.Term(2, 1);
t.write('\x1b[?25l');
assert.strictEqual(t.snapshot().cursor.visible, false);
t.write('\x1b[?25h');
assert.strictEqual(t.snapshot().cursor.visible, true);
// OSC titles are consumed, never printed.
t = new VT.Term(8, 1);
t.write('\x1b]0;title\x07ok');
assert.strictEqual(t.textLines()[0], 'ok');
console.log('vt selftest OK');
"#,
bundle = bundle
);
let out = std::process::Command::new("node")
.arg("-")
.arg("--input-type=commonjs")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.and_then(|mut child| {
use std::io::Write;
child.stdin.take().unwrap().write_all(script.as_bytes())?;
child.wait_with_output()
})
.expect("node runs");
let _ = std::fs::remove_dir_all(&dir);
assert!(
out.status.success() && String::from_utf8_lossy(&out.stdout).contains("vt selftest OK"),
"vt selftest failed:\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn skipped_workflow_step_renders_as_a_dim_slashed_row() {
let mut store = store_with_cast_proc(ProcStatus::Skipped);
{
let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
p.cast_path = None; p.detail = Some("skipped — its when: gate is false".into());
p.note = Some("step 2/2 · needs probe_credentials".into());
}
let html = session_page(&store, "castab").expect("session renders");
let procs = session_procs_html(&html);
assert!(procs.contains(r#"class="proc skipped""#), "got: {procs}");
assert!(!procs.contains("class=\"glyph\""), "proc rows no longer carry a status glyph: {procs}");
assert!(procs.contains(">skipped</span>"), "skipped elapsed phrase: {procs}");
assert!(
procs.contains(r#"<span class="note dim">skipped — its when: gate is false</span>"#),
"skip reason in the collapsed row: {procs}"
);
assert!(!procs.contains("data-proc-stop"), "skipped step has no Force stop: {procs}");
let js = live_client_js();
assert!(js.contains("function elapsedPhrase"));
assert!(js.contains("'skipped'"));
assert!(js.contains("function wfDisplayState"), "workflow graph live state");
}
#[test]
fn start_panel_offers_project_creation_and_the_client_wires_it() {
let store = Store::new(DaemonMode::Persistent, 7274, 1);
let html = super::index_page(&store);
for id in ["project-name", "project-create"] {
assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
}
assert!(html.contains("~/.scsh/projects/"), "the panel explains where projects live");
assert!(html.contains("start-controls"), "Run rows use start-controls for full-width layout");
assert!(html.contains("start-actions"), "Run action buttons are grouped for right alignment");
assert!(html.contains(".start-controls"), "start-controls CSS ships");
assert!(html.contains(".start-actions"), "start-actions CSS ships");
let js = live_client_js();
assert!(js.contains("/api/v1/projects/create"), "client js posts project creation");
assert!(js.contains("function createProject"), "client js wires the button");
assert!(js.contains("function handleRepoOpened"), "open and create share the response path");
assert!(js.contains("function showToast"), "invalid-name feedback is a toast");
assert!(!js.contains("suggestOpenExistingProject"), "existing names open via 200 create-or-open, not a toast");
assert!(js.contains("projectNameOk"), "client rejects dots/slashes before POST");
assert!(html.contains("no dots/slashes"), "placeholder documents the name rules");
assert!(html.contains(".toast"), "toast styles ship with the page");
assert!(html.contains(r#"class="section-label">Definitions"#), "defs panel is labeled Definitions");
assert!(js.contains("panel.scrollIntoView"), "open/create scrolls Definitions into view");
}
#[test]
fn live_client_js_parses_under_node() {
if crate::runtime::which("node").is_none() {
return;
}
let dir = std::env::temp_dir().join(format!("scsh-live-js-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("live-client.js");
std::fs::write(&path, live_client_js()).unwrap();
let out = std::process::Command::new("node").arg("--check").arg(&path).output().expect("spawn node --check");
assert!(
out.status.success(),
"live_client_js must parse: {}\n{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn running_cast_preview_starts_near_the_end() {
let js = live_client_js();
assert!(js.contains("box._live = true; createCastPlayer(box, 'end')"), "running casts open live at the edge");
assert!(!js.contains("near-end"), "the jittery near-end preview is gone");
assert!(js.contains("beecast-livechange"), "scsh tracks the player's live state");
}
#[test]
fn ui_review_fixes_hold() {
let html = super::index_page(&Store::new(DaemonMode::Persistent, 7274, 1));
assert!(
html.contains(".badge > span, .session-status > span, .agent-badge > span"),
"agent-badge text must sit above the chamfer overlay"
);
let js = live_client_js();
assert!(js.matches("scrollIntoView").count() >= 2, "def form + defs panel scroll into view");
let mut store = store_with_cast_proc(ProcStatus::Ok);
{
let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
p.detail = Some("2 + 3 = 5".into());
p.note = Some("claude run…".into());
}
let page = session_page(&store, "castab").expect("session renders");
assert!(page.contains(r#"<span class="note dim">2 + 3 = 5</span>"#), "the answer rides the collapsed row");
assert!(!page.contains(r#"<span class="note dim">claude run…</span>"#), "the stale note does not");
assert!(page.contains(r#"<div class="card card--accent-left-purple"><div class="session-actions">"#));
assert!(html.contains("details.proc.ok { border-left-color: var(--green); }"));
assert!(html.contains("details.proc.running { border-left-color: var(--orange); }"));
assert!(html.contains("details.proc.running summary .label { color: var(--orange); }"));
assert!(
html.contains(".wf-node.wf-running { border-left-color: var(--orange); }"),
"graph running matches proc orange"
);
assert!(html.contains(".wf-node.wf-stalled { border-left-color: var(--purple); }"), "abandoned/stalled is purple");
assert!(
html.contains(".wf-node.wf-force-stopped { border-left-color: var(--red); }"),
"force-stopped shares fail red"
);
assert!(js.contains("stalled:'Abandoned'"), "legend label is Abandoned");
{
let p = &mut store.sessions.get_mut("castab").unwrap().procs[0];
p.elapsed = Some(18.0);
}
let page_with_elapsed = session_page(&store, "castab").expect("session renders");
assert!(
page_with_elapsed.contains(r#"data-proc-elapsed="0">done in 18s</span>"#),
"ok rows say done in N: {page_with_elapsed}"
);
assert!(!page_with_elapsed.contains(r#"class="glyph""#), "no status glyph on proc rows");
assert!(html.contains(".badge--purple"), "purple badge class ships");
assert!(live_client_js().contains(r#"chamfer badge badge--purple"><span>builtin"#), "builtin badge is purple");
}
#[test]
fn session_header_carries_breadcrumbs_and_honest_kind() {
let mut store = store_with_cast_proc(ProcStatus::Running);
{
let s = store.sessions.get_mut("castab").unwrap();
s.kind = Some("workflow".into());
s.profile = Some("arith".into());
}
let html = session_page(&store, "castab").expect("session renders");
assert!(
html.contains(r#"<a href="/">scsh</a><span class="crumb-sep">›</span><a href="/">jobs</a><span class="crumb-sep">›</span><a class="job-id" href="/job/castab">castab</a>"#),
"breadcrumb permalinks in the top island (the id in a fixed font)"
);
assert!(
html.contains(r#"{}<span class="dot" aria-hidden="true"></span></span></div>"#.trim_start_matches("{}")),
"dot last in the island"
);
assert!(html.contains(r#"<span class="daemon-right">"#), "daemon status keeps the island's right side");
assert!(!html.contains("<h1>"), "the body no longer duplicates the path as an h1");
assert!(html.contains(r#"class="page-lede""#), "got lede: {html}");
assert!(html.contains("workflow <strong>arith</strong>"), "lede names the workflow: {html}");
assert!(!html.contains(r#"class="session-kind""#), "purple island no longer repeats kind: {html}");
assert!(!html.contains(r#"<ul class="skills">"#), "purple island drops skills list: {html}");
let mut old = store_with_cast_proc(ProcStatus::Running);
old.sessions.get_mut("castab").unwrap().profile = Some("default".into());
let html = session_page(&old, "castab").expect("session renders");
assert!(html.contains("profile <strong>default</strong>"), "lede defaults kind to profile: {html}");
assert!(!html.contains(r#"class="session-kind""#), "no session-kind on default profile: {html}");
let html = super::index_page(&store);
assert!(html.contains(r#"<span class="crumbs"><a href="/">scsh</a></span>"#), "got crumbs on index");
}
#[test]
fn stop_strip_and_kill_buttons_ignore_zombie_sessions() {
let store = store_with_cast_proc(ProcStatus::Running);
let html = super::index_page(&store);
assert!(!html.contains(r#"data-harness-stop=""#), "zombie sessions must not raise stop-all buttons");
let page = session_page(&store, "castab").expect("session renders");
assert!(!page.contains(r#"data-proc-stop="0""#), "zombie omits per-proc Force stop: {page}");
assert!(!page.contains(r#"id="session-stop""#), "zombie omits job Force stop: {page}");
let mut live = store_with_cast_proc(ProcStatus::Running);
live.sessions.get_mut("castab").unwrap().last_seen_at = crate::daemon::paths::now_unix_secs();
let html = super::index_page(&live);
assert!(html.contains(r#"data-harness-stop="claude""#), "live sessions raise the stop-all button");
let page = session_page(&live, "castab").expect("session renders");
assert!(page.contains(r#"data-proc-stop="0""#), "live sessions offer per-proc kill");
assert!(!page.contains(r#"data-proc-stop="0" disabled"#), "live kill is enabled: {page}");
assert!(page.contains(r#"id="session-stop""#), "live job offers Force stop");
}
#[test]
fn session_meta_agrees_with_lifecycle_badge() {
let zombie = store_with_cast_proc(ProcStatus::Running);
let page = session_page(&zombie, "castab").expect("session renders");
assert!(
page.contains("terminated abruptly") || page.contains("· terminated"),
"zombie lifecycle on lede: {page}"
);
assert!(!page.contains(r#"class="session-kind""#), "no island status chip: {page}");
assert!(page.contains(r#"data-session-ended>"#), "Ended present: {page}");
assert!(!page.contains(r#"data-session-ended>still running</dd>"#), "Ended must not contradict: {page}");
assert!(
!page.contains(r#"data-session-ended>terminated abruptly</dd>"#),
"Ended is a time, badge carries the phrase: {page}"
);
assert!(page.contains(r#"data-session-ended>19700101-000001 UTC</dd>"#), "Ended uses last_seen: {page}");
let repo = page.find("<dt>Repo</dt>").expect("Repo");
let branch = page.find("<dt>Branch</dt>").expect("Branch");
assert!(repo < branch, "Repo should sit above Branch: {page}");
assert!(page.contains(r#"data-session-started>"#), "meta is server-rendered on first paint");
assert!(page.contains(r#"data-last-seen="1""#), "last_seen seeds the client lifecycle");
let mut live = store_with_cast_proc(ProcStatus::Running);
live.sessions.get_mut("castab").unwrap().last_seen_at = crate::daemon::paths::now_unix_secs();
let page = session_page(&live, "castab").expect("session renders");
assert!(page.contains(r#"data-session-ended>still running</dd>"#), "live Ended: {page}");
assert!(page.contains(r#"id="session-stop""#), "live Force stop stays available");
}
#[test]
fn index_page_shows_colored_harness_chips_per_proc() {
let mut store = store_with_cast_proc(ProcStatus::Running);
{
let session = store.sessions.get_mut("castab").unwrap();
let mut done = session.procs[0].clone();
done.index = 1;
done.status = ProcStatus::Ok;
done.harness = Some("grok".into());
done.label = "grok: add".into();
session.procs.push(done);
let mut build = session.procs[0].clone();
build.index = 2;
build.kind = ProcKind::Build;
build.harness = Some("codex".into());
session.procs.push(build);
}
let html = super::index_page(&store);
assert!(
html.contains(r#"<span class="hchip hchip--claude" data-tip="claude · add" data-tip-running="1">C</span>"#),
"got: {html}"
);
assert!(
html.contains("<span class=\"hchip hchip--grok hchip--done\" data-tip=\"grok · add\ndone\">G</span>"),
"got: {html}"
);
assert!(!html.contains(r#"class="hchip hchip--codex"#), "build procs must not render a chip");
assert!(html.contains(".hchip--claude"));
assert!(html.contains(".hchip--codex"));
assert!(html.contains("function harnessChipsHtml"));
}
#[test]
fn index_page_carries_the_setup_panel_and_its_client_wiring() {
let store = Store::new(DaemonMode::Persistent, 7274, 1);
let html = super::index_page(&store);
assert!(html.contains("data-tab=\"setup\""), "nav label tab is Setup");
assert!(html.contains(">Setup</button>"), "nav shows Setup, not Containers");
assert!(!html.contains(">Containers</button>"), "Containers nav label is gone");
assert!(html.contains("id=\"tab-setup\""), "setup panel id");
assert!(html.contains("Harness setup"), "harness setup heading");
assert!(html.contains("id=\"setup-cards\""), "harness cards container");
assert!(html.contains("Images setup"), "images setup island");
assert!(html.contains("card--accent-left-purple"), "images island uses purple accent");
assert!(!html.contains("Advanced image management"), "no advanced disclosure");
for id in ["images-body", "images-build-selected", "images-build-all", "images-rebuild-base", "images-force"] {
assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
}
assert!(html.contains("checking…"), "skeleton starts in checking…");
for name in ["Claude", "Codex", "Grok", "Opencode", "Cursor"] {
assert!(html.contains(name), "harness card {name} on first paint");
}
assert!(html.contains("scsh-base:latest"), "base image row on first paint");
for tag in
["scsh-opencode:latest", "scsh-claude:latest", "scsh-codex:latest", "scsh-grok:latest", "scsh-cursor:latest"]
{
assert!(html.contains(tag), "harness image {tag} on first paint");
}
let js = live_client_js();
assert!(js.contains("/api/v1/setup"), "client js should fetch the setup API");
assert!(js.contains("/api/v1/setup/tests"), "client js posts model probes");
assert!(js.contains("/api/v1/images/build"), "client js should post builds");
assert!(js.contains("function refreshSetup"), "setup refresh");
assert!(js.contains("function startSetupTests"), "setup test starter");
assert!(js.contains("setup-test-all"), "Test all defaults control");
assert!(js.contains("data-setup-test"), "per-card Test selected");
assert!(js.contains("setupCustomModels"), "custom models persist in ui prefs");
assert!(js.contains("function markImagesChecking"), "refresh keeps rows visible while checking");
assert!(js.contains("id === 'images'"), "images tab id remains a compatibility alias");
assert!(!js.contains("loading…"), "must not replace the table with a blank loading row");
assert!(js.contains("data-image-build"), "per-row build buttons are rendered");
assert!(js.contains("data-setup-build"), "card Build/Update actions");
assert!(html.contains("id=\"setup-test-all\""), "Test all defaults on the toolbar");
assert!(js.contains("setup-models-hint"), "models section explains how to test");
assert!(js.contains("ready to test"), "summary uses ready-to-test wording");
assert!(js.contains("setupModelStatusHtml"), "model rows hide raw not_tested");
assert!(js.contains("setup-ready"), "ready-to-test badge styling");
assert!(js.contains("function startImageBuildOne"), "per-row build buttons are wired");
assert!(html.contains("image-action-cell"), "skeleton rows reserve the per-row action cell");
}
#[test]
fn index_page_carries_the_repositories_panel_and_its_client_wiring() {
let store = Store::new(DaemonMode::Persistent, 7274, 1);
let html = super::index_page(&store);
for id in
["repo-path", "repo-pick", "repo-open", "repo-blockers", "defs-panel", "defs-list", "def-form", "repos-body"]
{
assert!(html.contains(&format!("id=\"{id}\"")), "index page should contain #{id}");
}
for (tab, panel) in [("run", "tab-run"), ("jobs", "tab-jobs"), ("projects", "tab-projects"), ("setup", "tab-setup")] {
assert!(html.contains(&format!("data-tab=\"{tab}\"")), "index page should have the {tab} tab");
assert!(html.contains(&format!("id=\"{panel}\"")), "index page should have panel #{panel}");
}
let nav = html.find("<nav class=\"tabs\" role=\"tablist\">").expect("tabs nav");
let run_btn = html[nav..].find("data-tab=\"run\">Run</button>").expect("Run tab");
let jobs_btn = html[nav..].find("data-tab=\"jobs\">Jobs</button>").expect("Jobs tab");
assert!(run_btn < jobs_btn, "Run should be leftmost");
assert!(
html.contains("<section class=\"tab-panel active\" id=\"tab-run\" role=\"tabpanel\" aria-labelledby=\"tabbtn-run\">"),
"Run panel active by default"
);
assert!(html.contains("class=\"tab active\" data-tab=\"run\">Run</button>"), "Run tab active by default");
let js = live_client_js();
assert!(js.contains("saved || 'run'"), "client default tab is Run");
assert!(js.contains("pathForTab"), "tabs use path URLs, not #tab= hashes");
assert!(!js.contains("'/#tab='"), "no hash-based tab navigation");
assert!(js.contains("'/projects'"), "Projects tab path is /projects");
assert!(super::index::IndexTab::from_path("/projects") == Some(super::index::IndexTab::Projects));
assert!(super::index::IndexTab::from_path("/setup") == Some(super::index::IndexTab::Setup));
assert!(super::index::IndexTab::from_path("/images") == Some(super::index::IndexTab::Setup));
assert!(super::index::IndexTab::from_path("/jobs") == Some(super::index::IndexTab::Jobs));
assert!(super::index::IndexTab::from_path("/") == Some(super::index::IndexTab::Run));
assert!(js.contains("/api/v1/repos/open"), "client js opens a repo");
assert!(js.contains("/api/v1/repos/pick"), "client js pops the folder picker");
assert!(js.contains("/api/v1/jobs/start"), "client js starts a job");
assert!(js.contains("function renderRepoJobs"), "client js renders jobs by repository");
assert!(js.contains("function renderInternalJobs"), "client js renders Internal section");
assert!(js.contains("Chapters pending ⬇"), "export label for pending chapters");
assert!(js.contains("function syncChaptersPending"), "live chapters-pending sync");
assert!(
js.contains("SESSION_ID && liveSessions ? liveSessions[SESSION_ID]"),
"pollForChapters must not index null liveSessions (TypeError reading session id)"
);
assert!(!js.contains("SESSION_ID && liveSessions[SESSION_ID]"), "no bare liveSessions[SESSION_ID] without null check");
assert!(js.contains("const live = life === 'running'"), "Ready only while the job is live");
assert!(js.contains("OPEN_REPO_RUNNABLE"), "client js gates Start on the repo being runnable");
assert!(js.contains("function initTabs"), "client js wires the tabs");
assert!(js.contains("history.pushState"), "tab clicks push history (WEB-UI §1)");
assert!(js.contains("popstate"), "Back/Forward restore the active tab");
assert!(js.contains("function sessionEndedLabel"), "Ended label shares lifecycle with the badge");
assert!(js.contains("function activateProcPanel"), "fleet and workflow share arrival cues");
assert!(js.contains("localStorage"), "UI prefs persist (WEB-UI §7)");
assert!(!js.contains("fonts.googleapis.com"), "no Google Fonts in the live client");
}
#[test]
fn session_proc_html_has_no_stray_backslashes() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"test".into(),
Session {
id: "test".into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: crate::daemon::paths::now_unix_secs(), client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "opencode: add".into(),
status: ProcStatus::Running,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("opencode".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: None,
lines: vec![crate::daemon::model::OutputLine { at: 0.5, text: "building…".into() }],
}],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "test").expect("session page");
let procs = session_procs_html(&html);
assert!(!html.contains("\\\n"), "raw-string line continuations must not leak backslashes");
assert!(!procs.contains("\\\n"), "proc markup must not leak backslashes");
assert!(!procs.contains("autoscroll-ctl"), "no Auto-scroll checkbox");
assert!(!procs.contains("Auto-scroll to bottom"), "no Auto-scroll checkbox");
assert!(html.contains(r#"id="session-stop""#), "running session should offer Force stop");
assert!(html.contains("Force stop"));
}
#[test]
fn session_page_shows_the_commits_diff_chip_only_when_packed() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"difjob".into(),
Session {
id: "difjob".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![
ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "opencode: add".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: Some("/tmp/scsh-home/sessions/difjob/diffs/add-p0.html".into()),
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("opencode".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(2.0),
lines: vec![],
},
ProcRecord {
index: 1,
kind: ProcKind::Skill,
label: "claude: add".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(2.0),
lines: vec![],
},
],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "difjob").expect("session page");
let procs = session_procs_html(&html);
assert!(procs.contains(r#"href="/diff/difjob/0""#), "packed step links its diff: {procs}");
assert!(procs.contains("⇄ commits diff"), "the chip is labeled: {procs}");
assert!(!procs.contains(r#"href="/diff/difjob/1""#), "unpacked step has no diff link: {procs}");
assert_eq!(procs.matches("data-proc-diff").count(), 1, "exactly one chip: {procs}");
assert!(!procs.contains("target="), "no target override on the diff chip: {procs}");
}
#[test]
fn ended_session_hides_force_stop_button() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"done01".into(),
Session {
id: "done01".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "done01").expect("session page");
assert!(!html.contains(r#"id="session-stop""#), "ended session hides Force stop: {html}");
assert!(html.contains(r#"class="page-lede""#), "session page has a plain-language lede");
assert!(
html.contains("· completed ·") || html.contains("completed"),
"lede carries the completed lifecycle: {html}"
);
assert!(
!html.contains(r#"class="session-kind""#),
"ended session has no session-kind heading in the island: {html}"
);
assert!(
!html.contains(r#"session-actions"><span class="chamfer session-status"#),
"no badge in the top-right actions slot"
);
assert!(!html.contains(r#"<ul class="skills">"#), "no skills list in purple island");
}
#[test]
fn offline_export_carries_lede_and_full_meta() {
let session = Session {
id: "exp01".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("code-review".into()),
kind: Some("profile".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![],
workflow: None,
parent_session: None,
};
let html = session_export_page(&session, &[], 100);
assert!(html.contains(r#"class="page-lede""#), "export carries the live page's lede: {html}");
assert!(
html.contains("profile <strong>code-review</strong> · completed · 0 tasks."),
"lede shows kind, profile, lifecycle, and task count: {html}"
);
assert!(html.contains(r#"<dl class="session-meta">"#), "export keeps session-meta: {html}");
assert!(html.contains("<code>exp01</code>"), "job id in meta: {html}");
assert!(html.contains("<dt>Ended</dt>"), "export meta shows when the job ended: {html}");
assert!(html.contains("<dt>Duration</dt><dd>9s</dd>"), "export meta shows how long the job took: {html}");
assert!(html.contains("accessibility: 'snapshot'"), "export player opts enable a11y snapshot");
assert!(!html.contains("Force stop"), "export must not mention Force stop: {html}");
assert!(!html.contains("session-stop"), "export must not ship #session-stop: {html}");
assert!(!html.contains("proc-kill"), "export must not ship .proc-kill: {html}");
assert!(!html.contains("scsh-dialog"), "export must not ship the Force stop dialog: {html}");
}
#[test]
fn offline_export_embeds_commits_diff_when_present() {
use super::session_export::CastExport;
let session = Session {
id: "expdf".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("default".into()),
kind: Some("profile".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "opencode: add".into(),
status: ProcStatus::Ok,
note: None,
detail: Some("ok".into()),
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: Some("/tmp/diff.html".into()),
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("opencode".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(1.0),
lines: vec![],
}],
workflow: None,
parent_session: None,
};
let hostile = r#"<html><body></script><p>diff</p></body></html>"#;
let exports = [CastExport::Note { text: "no recording".into(), diff_html: Some(hostile.into()) }];
let html = session_export_page(&session, &exports, 100);
assert!(html.contains(r#"<span class="proc-diff""#), "summary carries static commits-diff chip");
assert!(html.contains(r#"<details class="proc-diff">"#), "body embeds the packed diff");
assert!(html.contains("srcdoc="), "diff rides in an iframe srcdoc");
assert!(
html.contains(r#"sandbox="allow-scripts allow-same-origin""#),
"packdiff ≥ 0.3 needs scripts + same-origin for WASM/localStorage: {html}"
);
assert!(html.contains("<\\/"), "hostile </ is broken for srcdoc like CASTS");
assert!(!html.contains("</script><p>diff"), "raw </script> must not appear unescaped");
}
#[test]
fn offline_export_keeps_text_log_lines() {
use super::session_export::CastExport;
use crate::daemon::model::OutputLine;
let session = Session {
id: "explog".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("default".into()),
kind: Some("profile".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Build,
label: "build: claude".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: None,
model: None,
started_at: Some(1),
elapsed: Some(9.0),
lines: vec![
OutputLine { at: 0.5, text: "Step 1/4 : FROM ubuntu".into() },
OutputLine { at: 2.0, text: "<hostile> & escaped".into() },
],
}],
workflow: None,
parent_session: None,
};
let note = "no recording — image build ran without asciinema on PATH (text log only)";
let exports = [CastExport::Note { text: note.into(), diff_html: None }];
let html = session_export_page(&session, &exports, 100);
assert!(html.contains(r#"<div class="output">"#), "export embeds the text-log output box: {html}");
assert!(
html.contains(r#"<div class="line"><span class="at">+0.5s</span> Step 1/4 : FROM ubuntu</div>"#),
"export keeps the live page's timestamped line markup: {html}"
);
assert!(html.contains("<hostile> & escaped"), "log lines are HTML-escaped: {html}");
assert!(!html.contains(note), "the one-line note gives way to the actual log lines: {html}");
let mut bare = session.clone();
bare.procs[0].lines.clear();
let exports = [CastExport::Note { text: note.into(), diff_html: None }];
let bare_html = session_export_page(&bare, &exports, 100);
assert!(bare_html.contains(note), "no lines → the note explains why there is nothing to embed: {bare_html}");
assert!(!bare_html.contains(r#"<div class="output">"#), "no lines → no empty output box: {bare_html}");
}
#[test]
fn offline_export_includes_workflow_graph() {
use super::session_export::CastExport;
use crate::daemon::workflow::{WorkflowMeta, WorkflowNodeMeta};
let session = Session {
id: "expwf".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("arith".into()),
kind: Some("workflow".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![
ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "claude: add".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(4.0),
lines: vec![],
},
ProcRecord {
index: 1,
kind: ProcKind::Skill,
label: "codex: summarize".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("summarize".into()),
route: None,
result_path: None,
annotate_target: None,
harness: Some("codex".into()),
skill_name: Some("summarize".into()),
model: None,
started_at: Some(5),
elapsed: Some(5.0),
lines: vec![],
},
],
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "add".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "summarize".into(),
proc_index: Some(1),
order: 1,
needs: vec!["add".into()],
conditional: false,
when_summary: None,
},
],
}),
parent_session: None,
};
let exports = [
CastExport::Note { text: "no recording".into(), diff_html: None },
CastExport::Note { text: "no recording".into(), diff_html: None },
];
let html = session_export_page(&session, &exports, 100);
assert!(html.contains(r#"id="workflow-graph""#), "export carries the workflow card: {html}");
assert!(html.contains(r#"class="wf-bookend wf-start""#), "graph keeps its start bookend");
assert!(html.contains(r#"class="wf-bookend wf-finish""#), "graph keeps its finish bookend");
assert!(html.contains(r#"data-workflow-step="add""#) && html.contains(r#"data-workflow-step="summarize""#));
assert!(html.contains("href=\"#task-add\""), "node links target task anchors");
assert!(html.contains(r#"<details open class="proc ok" data-index="0" id="task-add""#), "proc row anchors: {html}");
assert!(html.contains(".wf-bookend"), "bookend CSS is inlined in the export");
}
#[test]
fn cast_play_page_accepts_query_time_deep_links() {
let page = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(page.contains("location.hash.match(/^#t=([0-9:.]+)$/)"), "hash form stays primary");
assert!(
page.contains("new URLSearchParams(location.search).get('t')"),
"query form is parsed from location.search: {page}"
);
assert!(page.contains("/^[0-9:.]+$/.test(q)"), "query values pass the same seconds/mm:ss shape check");
assert!(page.contains("'#t=' + t"), "the copy button still writes the hash form");
if crate::runtime::which("node").is_none() {
return;
}
let script = page.rsplit("<script>").next().expect("inline boot script");
let script = script.split("</script>").next().expect("script end");
let dir = std::env::temp_dir().join(format!("scsh-cast-js-check-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("cast-page.js");
std::fs::write(&path, script).unwrap();
let out = std::process::Command::new("node").arg("--check").arg(&path).output().expect("spawn node --check");
assert!(
out.status.success(),
"cast page inline script must parse: {}\n{}",
String::from_utf8_lossy(&out.stderr),
String::from_utf8_lossy(&out.stdout)
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn session_page_renders_fleet_comparison_for_shared_skill_source() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"fleet1".into(),
Session {
id: "fleet1".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("default".into()),
kind: Some("profile".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 10,
client_connected: false,
run_pid: None,
skills: vec![],
procs: vec![
ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "opencode: add-opencode".into(),
status: ProcStatus::Ok,
note: None,
detail: Some("2 + 3 = 5".into()),
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: Some("opencode".into()),
result_path: None,
annotate_target: None,
harness: Some("opencode".into()),
skill_name: Some("add-opencode".into()),
model: None,
started_at: Some(1),
elapsed: Some(1.0),
lines: vec![],
},
ProcRecord {
index: 1,
kind: ProcKind::Skill,
label: "claude: add-claude".into(),
status: ProcStatus::Ok,
note: None,
detail: Some("2 + 3 = 5".into()),
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: Some("claude".into()),
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add-claude".into()),
model: None,
started_at: Some(1),
elapsed: Some(1.2),
lines: vec![],
},
],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "fleet1").expect("session page");
assert!(html.contains(r#"class="fleets""#), "fleet section present: {html}");
assert!(html.contains(r#"class="fleet-compare""#), "comparison table present");
assert!(html.contains(r#"data-skill-source="add""#), "grouped by skill_source");
assert!(html.contains(r#"class="fleet-jump" data-proc="0""#), "jump to first route");
assert!(html.contains(r#"class="fleet-jump" data-proc="1""#), "jump to second route");
let fleets_at = html.find(r#"class="fleets""#).expect("fleets");
let procs_at = html.find(r#"id="session-procs""#).expect("procs");
assert!(fleets_at < procs_at, "fleet HTML sits before #session-procs");
}
#[test]
fn fleet_routes_stack_completed_before_running_before_waiting() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"fleet2".into(),
Session {
id: "fleet2".into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: Some("profile".into()),
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: crate::daemon::paths::now_unix_secs(),
client_connected: true,
run_pid: Some(1),
skills: vec![],
procs: vec![
ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "claude: add-waiting".into(),
status: ProcStatus::Waiting,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: Some("waiting-route".into()),
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add-waiting-route".into()),
model: None,
started_at: None,
elapsed: None,
lines: vec![],
},
ProcRecord {
index: 1,
kind: ProcKind::Skill,
label: "claude: add-done".into(),
status: ProcStatus::Ok,
note: None,
detail: Some("ok".into()),
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: Some("done-route".into()),
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add-done-route".into()),
model: None,
started_at: Some(1),
elapsed: Some(1.0),
lines: vec![],
},
ProcRecord {
index: 2,
kind: ProcKind::Skill,
label: "claude: add-running".into(),
status: ProcStatus::Running,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: Some("running-route".into()),
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add-running-route".into()),
model: None,
started_at: Some(1),
elapsed: None,
lines: vec![],
},
],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "fleet2").expect("fleet page");
let done_at = html.find("done-route").expect("done route");
let running_at = html.find("running-route").expect("running route");
let waiting_at = html.find("waiting-route").expect("waiting route");
assert!(done_at < running_at && running_at < waiting_at, "Completed → Running → Waiting: {html}");
}
#[test]
fn client_js_wires_fleet_jumps_and_accessibility_snapshot() {
let js = live_client_js();
assert!(js.contains("function parseIndexFilter"), "client parses /project and /repo");
assert!(js.contains("function repoFilterHref"), "client builds filter hrefs");
assert!(js.contains("repo-filter-link"), "live Projects rows stay clickable");
}
#[test]
fn client_js_wires_force_stop() {
let js = live_client_js();
assert!(js.contains("/api/v1/session/stop"), "client js posts session stop");
assert!(js.contains("function forceStopSession"), "client js defines forceStopSession");
assert!(js.contains("function scshConfirm"), "Force stop uses an in-app confirm dialog");
assert!(js.contains("scsh-dialog"), "dialog markup class ships");
assert!(!js.contains("confirm("), "no browser confirm() for Force stop");
assert!(!js.contains("alert("), "Force stop errors use toast, not alert()");
}
#[test]
fn dashboard_a11y_contracts_hold() {
let js = live_client_js();
assert!(js.contains("const prevFocus = document.activeElement;"), "dialog records the opener's focus");
assert!(js.contains("prevFocus.focus()"), "dialog restores focus on close");
assert!(js.contains("ev.key === 'Tab'"), "dialog traps Tab inside the modal");
let html = super::index_page(&Store::new(DaemonMode::Persistent, 7274, 1));
assert!(html.contains(r#"<nav class="tabs" role="tablist">"#), "the tabs nav is a tablist");
assert_eq!(html.matches(r#"role="tabpanel""#).count(), 4, "every panel is a tabpanel");
assert!(html.contains(r#"aria-labelledby="tabbtn-run""#), "panels are labelled by their tabs");
assert!(html.contains(r#"aria-selected="true""#), "the active tab is selected server-side");
assert!(js.contains("'ArrowRight'"), "arrow keys walk the tablist");
assert!(js.contains("x.tabIndex = on ? 0 : -1;"), "roving tabindex follows the active tab");
assert!(js.contains("panel.scrollIntoView"), "open/create still scrolls Definitions into view");
assert!(!js.contains("behavior: 'smooth'"), "no hardcoded smooth scrolling");
let cast = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(cast.contains(r#"<span id="copied" role="status">"#), "copy feedback is a live region");
assert!(
html.contains("min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;"),
"crumbs truncate with an ellipsis"
);
let gate = html.find("@media (prefers-reduced-motion: no-preference)").expect("reduced-motion gate ships");
for rule in [".btn, button.btn { transition:", ".hchip { transition:", ".toast { transition:"] {
assert_eq!(html.matches(rule).count(), 1, "{rule} appears exactly once");
assert!(html.find(rule).unwrap() > gate, "{rule} is gated on reduced motion");
}
}
#[test]
fn client_js_mirrors_the_commits_diff_chip() {
let js = live_client_js();
assert!(js.contains("function procDiffBtnHtml"), "client js builds the diff chip");
assert!(js.contains("p.diff_path"), "client js keys the chip on the proc's diff_path");
assert!(js.contains("⇄ commits diff"), "the live chip carries the same label");
assert!(js.contains("initProcDiffs"), "chips present at page render are wired too");
}
#[test]
fn recorded_proc_embeds_cast_player_instead_of_text_output() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"castab".into(),
Session {
id: "castab".into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 1,
client_connected: true,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 2,
kind: ProcKind::Skill,
label: "claude: add".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: Some("/tmp/x.cast".into()),
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(3.0),
lines: vec![],
}],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "castab").expect("session page");
assert!(html.contains(r#"<link rel="stylesheet" href="/assets/scsh-cast-player.css">"#), "player css");
assert!(html.contains(r#"<script src="/assets/scsh-cast-player.js"></script>"#), "player js");
let procs = session_procs_html(&html);
assert!(procs.contains(r#"<div class="cast" data-cast-url="/cast/castab/2""#), "cast embed");
assert!(!procs.contains("data-cast-fs"), "no page-side fullscreen button");
assert!(procs.contains("f fullscreen"), "the keys hint teaches f");
assert!(!procs.contains("data-cast-reload"), "no manual reload in a streaming toolbar");
assert!(procs.contains("c chapters"), "the keys hint teaches the chapters panel");
let js = live_client_js();
assert!(!js.contains("data-cast-reload"), "client js builds no reload button");
assert!(!js.contains("cast-chapters"), "no scsh-side chapter chips");
assert!(!js.contains("cast-fs-chapters"), "no scsh-side fullscreen chapters sidebar");
assert!(js.contains("markers"), "chapters reach the player as markers");
assert!(js.contains("function focusCastPlayer"), "open sections hand the player the keyboard");
assert!(js.contains("if (det.open) focusCastPlayer(box)"), "focus follows the section toggle");
assert!(!procs.contains("data-cast-link"), "no link-at-time in the inline toolbar");
assert!(procs.contains(r#"class="chamfer btn btn--cyan btn--sm proc-snapshot""#), "run snapshot link");
assert!(procs.contains(r#"href="/cast/castab/2/export.html" data-cast-export"#), "run snapshot href");
assert!(
!procs.contains(r#"cast-toolbar"><a href="/cast/castab/2/export.html"#),
"snapshot is outside the cast toolbar"
);
assert!(procs.contains(r#"<a href="/cast/castab/2?dl=1" download>"#), "download link");
assert!(!procs.contains(r#"<div class="output">"#), "no text output for recorded proc");
assert!(!procs.contains("autoscroll-ctl"), "no autoscroll control for recorded proc");
}
#[test]
fn session_proc_html_has_no_autoscroll_checkbox() {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"test".into(),
Session {
id: "test".into(),
started_at: 1,
ended_at: None,
profile: Some("default".into()),
kind: None,
repo: "/tmp/repo".into(),
branch: "main".into(),
last_seen_at: 1,
client_connected: true,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "opencode: add".into(),
status: ProcStatus::Running,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: None,
harness: Some("opencode".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: None,
lines: vec![crate::daemon::model::OutputLine { at: 0.2, text: "cloning…".into() }],
}],
workflow: None,
parent_session: None,
},
);
let html = session_page(&store, "test").expect("session page");
let procs = session_procs_html(&html);
assert!(procs.contains(r#"<div class="output">"#), "text fallback output remains");
assert!(!procs.contains("autoscroll-ctl"), "Auto-scroll checkbox removed");
assert!(!procs.contains("Auto-scroll to bottom"), "Auto-scroll checkbox removed");
let js = live_client_js();
assert!(!js.contains("Auto-scroll to bottom"), "client JS has no Auto-scroll checkbox");
assert!(js.contains("followOutput"), "sticky follow without a checkbox");
}
fn store_with_annotate_proc(status: ProcStatus) -> Store {
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"annjob".into(),
Session {
id: "annjob".into(),
started_at: 1,
ended_at: None,
profile: Some("annotate".into()),
kind: Some("annotate".into()),
repo: "(internal)".into(),
branch: "".into(),
last_seen_at: crate::daemon::paths::now_unix_secs(),
client_connected: true,
run_pid: None,
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Annotate,
label: "annotate · add-20260711-114749-utc-ufakca".into(),
status,
note: Some("summarizing…".into()),
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: None,
route: None,
result_path: None,
annotate_target: Some("/tmp/casts/add-20260711-114749-utc-ufakca.cast".into()),
harness: Some("cursor".into()),
skill_name: None,
model: Some("composer".into()),
started_at: Some(1),
elapsed: None,
lines: vec![],
}],
workflow: None,
parent_session: None,
},
);
store
}
#[test]
fn annotate_rows_render_slim_without_the_retired_terminal_chrome() {
for status in [ProcStatus::Running, ProcStatus::Ok, ProcStatus::Fail] {
let html = session_page(&store_with_annotate_proc(status), "annjob").expect("session page");
let procs = session_procs_html(&html);
assert!(procs.contains("annotate · add-20260711-114749-utc-ufakca"), "row renders: {procs}");
if status == ProcStatus::Running {
assert!(procs.contains("Force stop"), "per-proc Force stop while live");
} else {
assert!(!procs.contains("Force stop"), "no Force stop on a settled row ({status:?})");
}
assert!(!procs.contains("autoscroll-ctl"), "no auto-scroll control on a slim row ({status:?}): {procs}");
assert!(!procs.contains(r#"<div class="output">"#), "no output box on a slim row ({status:?}): {procs}");
assert!(!procs.contains("No output"), "no empty-output placeholder ({status:?}): {procs}");
}
let js = live_client_js();
assert!(
js.contains("lines.length ? '<div class=\"output\">'"),
"procHtml keeps line-less procs slim"
);
assert!(js.contains("if (!lines.length || hasCast(p)) return;"), "syncProcOutput never creates an empty box");
assert!(!js.contains("No output yet."), "the retired empty-output placeholder is gone from the client");
}
#[test]
fn chapters_pending_note_links_to_the_summarizing_job() {
let js = live_client_js();
assert!(js.contains("meta.summarizing_job"), "the poll reads the summarizing job id");
assert!(
js.contains(r#"pending.innerHTML = '⏳ chapters: <a href="/job/' + esc(jobId) + '">summarizing…</a>';"#),
"known id → the pending element is a normal /job/ link"
);
assert!(js.contains("pending.textContent = '⏳ chapters: summarizing…';"), "unknown id → plain text, no dead link");
assert!(js.contains("CHAPTERS_WAIT_SECS"), "the poll window is still bounded");
assert!(js.contains("setChapPending(pending, meta.summarizing_job)"), "a late-registering job links up mid-poll");
}
#[test]
fn empty_cast_shows_placeholder_instead_of_player_error() {
let js = live_client_js();
assert!(js.contains("Recording in progress — no frames yet."));
assert!(js.contains("No recorded frames."));
assert!(js.contains("cast-placeholder"));
assert!(js.contains("{ data: text }"), "player mounts over the already-fetched text");
let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
assert!(page.contains("Recording in progress — no frames yet."));
assert!(page.contains("cast-placeholder"));
assert!(page.contains("const LIVE = true;"));
let done = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(done.contains("const LIVE = false;"));
}
#[test]
fn cast_growth_notifications_append_in_place() {
let js = live_client_js();
assert!(js.contains("if (msg.type === 'cast_growth') { onCastGrowth(msg); return; }"));
assert!(js.contains("onWsMessage(JSON.parse(ev.data))"));
assert!(js.contains("function followCastGrowth"));
assert!(js.contains("box._player.append(text.slice(prev))"));
assert!(!js.contains("Recording grew: +"), "the reload banner is gone — growth is invisible and smooth");
let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
assert!(page.contains("'cast_growth'"));
assert!(page.contains("const SESSION = 'castab';"));
assert!(page.contains("const PROC = 0;"));
assert!(page.contains("player.append(text.slice(loadedChars))"));
assert!(!page.contains("Recording grew: +"));
assert!(page.contains("if (!castRunning) return;"), "no WS connect once the proc finished");
assert!(super::PLAYER_JS.contains("Player.prototype.append"), "the vendored player must have append");
assert!(super::PLAYER_JS.contains("appendCast"), "the DOM-free core must parse appends");
}
#[test]
fn live_follows_from_player_toolbar_not_scsh_chrome() {
let running =
super::proc::cast_embed_html("castab", &store_with_cast_proc(ProcStatus::Running).sessions["castab"].procs[0]);
assert!(!running.contains("data-cast-live"), "session embed has no scsh Live button");
let done = super::proc::cast_embed_html("castab", &store_with_cast_proc(ProcStatus::Ok).sessions["castab"].procs[0]);
assert!(!done.contains("data-cast-live"));
let js = live_client_js();
assert!(js.contains("function setCastLive(box, on)"));
assert!(js.contains("box._player.setLive(true)"));
assert!(js.contains("controls: running ? { live: true } : true"), "running casts enable player Live control");
assert!(js.contains("live: !!(box._live || running)"), "running casts start declared-live");
let page = cast_player_page(&store_with_cast_proc(ProcStatus::Running), "castab", 0).expect("player page");
assert!(!page.contains("live-toggle"), "standalone page has no external Live button");
assert!(page.contains("controls: wantLive ? { live: true } : true"));
let finished = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(!finished.contains("live-toggle"));
}
#[test]
fn export_html_download_renders_on_both_pages_and_hides_without_frames() {
let page = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(page.contains(r#"<a id="dl-html" href="/cast/castab/0/export.html" download hidden>⬇ download .html</a>"#));
assert!(page.contains("document.getElementById('dl-html').hidden = !stats.events;"));
let session = session_page(&store_with_cast_proc(ProcStatus::Ok), "castab").expect("session page");
let procs = session_procs_html(&session);
assert!(
procs.contains(r#"class="chamfer btn btn--cyan btn--sm proc-snapshot""#)
&& procs.contains(r#"href="/cast/castab/0/export.html" data-cast-export download hidden"#)
&& procs.contains("<span>Run snapshot ⬇</span>"),
"run snapshot button: {procs}"
);
let js = live_client_js();
assert!(js.contains("ensureProcSnapshot"));
assert!(js.contains("exportLink.hidden = !stats.events;"));
assert!(js.contains("Incomplete run ⬇"), "live cast export says incomplete run while running");
assert!(js.contains("Run snapshot ⬇"), "finished cast export says run snapshot");
}
#[test]
fn session_page_header_offers_the_session_export_download() {
let html = session_page(&store_with_cast_proc(ProcStatus::Ok), "castab").expect("session page");
assert!(
html.contains(r#"href="/job/castab/export.html" download"#) && html.contains("session-export"),
"session export button"
);
let mut store = store_with_cast_proc(ProcStatus::Ok);
store.sessions.get_mut("castab").unwrap().procs[0].cast_path = None;
let bare = session_page(&store, "castab").expect("session page");
assert!(!bare.contains("<a class=\"session-export\""), "no export button without any registered cast");
}
#[test]
fn live_client_js_counts_alive_clients_and_shutdown() {
let js = live_client_js();
assert!(js.contains("alive_clients"));
assert!(js.contains("shutting down in"));
}
#[test]
fn live_client_js_skips_index_render_without_sessions() {
let js = live_client_js();
assert!(js.contains("if (!body || sessions == null) return"));
assert!(js.contains("if (snapshot) {"));
assert!(js.contains("renderIndex(snapshot, nowUnix)"));
}
#[test]
fn live_client_js_shows_connecting_on_ws_close() {
let js = live_client_js();
assert!(js.contains("setDaemonStatus('connecting', 'connecting…', null)"));
assert!(!js.contains("daemon unreachable"));
}
#[test]
fn wrap_page_connecting_status_uses_blue() {
use super::layout::wrap_page;
let html = wrap_page("scsh sessions", 7274, None, "", "<p>body</p>");
assert!(html.contains("class=\"daemon-status connecting\""));
assert!(html.contains(".daemon-status.connecting .dot { background: var(--cyan);"));
assert!(!html.contains("fonts.googleapis.com"), "offline-first: no CDN fonts (WEB-UI §5)");
assert!(html.contains("position: sticky"), "status chrome is pinned");
assert!(html.contains("--daemon-status-height"), "tabs stick flush under a shared status height");
assert!(!html.contains("top: 3.1rem"), "no hard-coded sticky gap above the tabs");
assert!(html.contains("width: 100%"), "status chrome spans the viewport");
assert!(html.contains(r#"class="page-shell""#), "content sits in a centered column under the bar");
}
#[test]
fn every_daemon_page_carries_the_inline_favicon() {
use super::layout::wrap_page;
let html = wrap_page("scsh sessions", 7274, None, "", "<p>body</p>");
assert!(html.contains("<link rel=\"icon\" href=\"data:image/svg+xml,"), "dashboard favicon");
let player = cast_player_page(&store_with_cast_proc(ProcStatus::Ok), "castab", 0).expect("player page");
assert!(player.contains("<link rel=\"icon\" href=\"data:image/svg+xml,"), "player-page favicon");
assert!(!player.contains("fonts.googleapis.com"), "cast player page is offline-first too");
}
#[test]
fn wrap_page_serves_valid_css_braces() {
use super::layout::wrap_page;
let html = wrap_page("scsh sessions", 7274, None, "Hello lede", "<p>body</p>");
assert!(html.contains(":root {"));
assert!(!html.contains(":root {{"));
assert!(html.contains(".daemon-status {"));
assert!(html.contains(r#"class="page-lede""#), "lede renders in the content column");
assert!(html.contains("Hello lede"));
let status_at = html.find(r#"id="daemon-status""#).expect("status bar");
let shell_at = html.find(r#"class="page-shell""#).expect("page shell");
let lede_at = html.find(r#"class="page-lede""#).expect("lede");
assert!(status_at < shell_at && shell_at < lede_at, "chrome, then shell, then lede");
}
#[test]
fn review_round_four_fixes_hold() {
use crate::daemon::model::OpenRepo;
let mut store = store_with_cast_proc(ProcStatus::Ok);
store.sessions.get_mut("castab").unwrap().ended_at = Some(5);
store.open_repo(OpenRepo { path: "/work/empty".into(), opened_at: 9, clean: true });
let html = super::index_page(&store);
assert!(html.contains(r#"class="repo-filter-link""#), "project/repo names are filter links");
assert!(html.contains(r#"title="/tmp/repo""#) || html.contains(r#"title="/tmp/repo">"#), "got: {html}");
assert!(html.contains("/repo/tmp/repo") || html.contains("href=\"/repo/tmp/repo\""), "repo filter href: {html}");
assert!(
html.contains(
r#"<div class="repo-jobgroup"><span class="repo-jobgroup-name">default</span><div class="repo-job"><span class="chamfer session-status completed"><span>completed</span></span> <a class="job-id" href="/job/castab">castab</a> <span class="dim">"#
),
"got: {html}"
);
assert!(
html.contains(r#"title="/work/empty""#)
&& html.contains(r#"href="/repo/work/empty""#)
&& html.contains(r#"no jobs yet"#),
"got: {html}"
);
assert!(html.contains(r#"<span class="chip-count" data-tip="1 run in this job">1</span>"#), "got: {html}");
assert!(html.contains(".ui-tip"), "tooltip CSS ships");
assert!(html.contains("initTips"), "tooltip delegation ships");
assert!(!super::index_page(&store).contains(r#"hchip--claude hchip--done" title="#), "chips use data-tip, not title");
assert!(html.contains("<th>Job</th>"), "got: {html}");
assert!(!html.contains("<th>Session</th>"));
let mut ended = store_with_cast_proc(ProcStatus::Ok);
ended.sessions.get_mut("castab").unwrap().procs[0].elapsed = Some(30.0);
let shtml = session_page(&ended, "castab").expect("session renders");
assert!(shtml.contains(r#" data-status="ok" data-ended="31">"#), "got: {shtml}");
assert!(shtml.contains("CHAPTERS_WAIT_SECS"), "bounded summarizing window ships");
let running = session_page(&store_with_cast_proc(ProcStatus::Running), "castab").expect("session renders");
assert!(running.contains(r#" data-status="running">"#), "got: {running}");
assert!(!running.contains(r#" data-status="running" data-ended"#), "got: {running}");
let mut live = store_with_cast_proc(ProcStatus::Running);
live.sessions.get_mut("castab").unwrap().last_seen_at = crate::daemon::paths::now_unix_secs();
let shtml = session_page(&live, "castab").expect("session renders");
assert!(shtml.contains(">Force stop</button>") || shtml.contains("<span>Force stop</span>"), "got: {shtml}");
assert!(!shtml.contains("✕ Force stop"), "no leading ✕ on Force stop");
assert!(!shtml.contains("✕ kill"));
assert!(shtml.contains("<span>Incomplete job ⬇</span>"), "running job export says incomplete job: {shtml}");
assert!(shtml.contains(r#"class="chamfer btn btn--cyan btn--sm session-export""#), "export matches Force stop size");
assert!(
shtml.find("session-export").unwrap() < shtml.find("id=\"session-stop\"").unwrap(),
"job snapshot sits above Force stop in the actions stack"
);
assert!(shtml.contains("Job snapshot ⬇") || shtml.contains("Incomplete job ⬇"), "snapshot wording is explicit");
let done = session_page(&store_with_cast_proc(ProcStatus::Ok), "castab").expect("done session");
assert!(
done.contains("<span>Chapters pending ⬇</span>"),
"finished job missing sidecar uses chapters pending: {done}"
);
assert!(done.contains("1 cast finalizing chapters"), "pending counter on job page: {done}");
assert!(!done.contains("<span>Incomplete job ⬇</span>"), "finished job meta export is not incomplete");
assert!(!done.contains("<span>Incomplete run ⬇</span>"), "finished job has no incomplete-run label");
assert!(!done.contains(r#"<ul class="skills">"#), "no skills list on job page island");
let dir = std::env::temp_dir().join(format!("scsh-chap-ready-{}", crate::runtime::random_nonce_6()));
std::fs::create_dir_all(&dir).unwrap();
let cast = dir.join("ready.cast");
std::fs::write(&cast, "{\"version\":3}\n").unwrap();
std::fs::write(dir.join("ready.chapters.json"), r#"{"summary":"ok","chapters":[]}"#).unwrap();
let mut settled = store_with_cast_proc(ProcStatus::Ok);
{
let s = settled.sessions.get_mut("castab").unwrap();
s.ended_at = Some(10);
s.procs[0].cast_path = Some(cast.to_string_lossy().into_owned());
}
let settled_html = session_page(&settled, "castab").expect("settled session");
assert!(
settled_html.contains("<span>Job snapshot ⬇</span>"),
"settled job export label: {settled_html}"
);
assert!(
!settled_html.contains(r#"id="chapters-pending""#),
"no pending line when sidecar exists: {settled_html}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn review_round_five_fixes_hold() {
let now = crate::daemon::paths::now_unix_secs();
let mut store = store_with_cast_proc(ProcStatus::Ok);
store.sessions.get_mut("castab").unwrap().ended_at = Some(5);
{
let done = store.sessions.get("castab").unwrap().clone();
let mut live = done.clone();
live.id = "livejb".into();
live.ended_at = None;
live.last_seen_at = now;
live.profile = Some("arith".into());
live.procs[0].status = ProcStatus::Running;
store.sessions.insert("livejb".into(), live);
}
let html = super::index_page(&store);
let arith = html.find(r#"<span class="repo-jobgroup-name">arith</span>"#).expect("arith group");
let default = html.find(r#"<span class="repo-jobgroup-name">default</span>"#).expect("default group");
assert!(arith < default, "the group with a running job sorts above the finished one: {html}");
assert!(html.contains(r#"<span class="chamfer session-status running"><span>running</span></span> <a class="job-id" href="/job/livejb">livejb</a> <span class="dim">"#), "got: {html}");
assert!(html.contains(r#"data-tab="run">Run</button>"#), "got: {html}");
assert!(!html.contains("New job"));
assert!(!html.contains("Start a job"));
{
let mut img = store_with_cast_proc(ProcStatus::Ok);
img.sessions.get_mut("castab").unwrap().repo = crate::daemon::server::IMAGE_BUILDS_REPO.into();
img.sessions.get_mut("castab").unwrap().profile = Some("build-images".into());
img.sessions.get_mut("castab").unwrap().ended_at = Some(5);
let html = super::index_page(&img);
assert!(html.contains(r#"id="internal-jobs-card""#), "Internal section present: {html}");
assert!(html.contains(r#"<p class="section-label">Internal</p>"#), "Internal label: {html}");
assert!(html.contains(r#"<span class="repo-jobgroup-name">build-images</span>"#), "grouped by profile: {html}");
assert!(html.contains(r#"href="/job/castab""#), "job link in Internal: {html}");
assert!(
!html.contains(r#"data-repo="(image builds)""#),
"image builds excluded from Projects table: {html}"
);
}
assert_eq!(super::format::format_short_age(45), "45s");
assert_eq!(super::format::format_short_age(200), "3m");
assert_eq!(super::format::format_short_age(7300), "2h");
assert_eq!(super::format::format_short_age(200_000), "2d");
assert!(html.contains("function formatShortAge"), "JS mirror ships");
assert!(html.contains(".repo-jobgroup"), "group CSS ships");
let shtml = session_page(&store, "castab").expect("session renders");
assert!(!shtml.contains("sizeCastPane"), "the pane-sizing workaround must stay gone");
assert!(!shtml.contains(".cast-player { width: 100%; height:"), "no forced pane height");
assert!(shtml.contains("height: 100% !important"), "fullscreen fills the grid cell for a stable mount measure");
assert!(shtml.contains(".cast:fullscreen .beecast-player"), "fullscreen styles the player root");
}
#[test]
fn project_and_repo_filter_urls_normalize_extra_slashes() {
use super::index::{parse_index_filter, IndexFilter};
assert_eq!(parse_index_filter("/project//demo-1/"), Some(IndexFilter::Project("demo-1".into())));
assert_eq!(parse_index_filter("/project/demo-1"), Some(IndexFilter::Project("demo-1".into())));
assert_eq!(parse_index_filter("/project/"), None);
assert_eq!(parse_index_filter("/project"), None);
assert_eq!(parse_index_filter("/repo///Users/dima/foo/"), Some(IndexFilter::Repo("/Users/dima/foo".into())));
assert_eq!(parse_index_filter("/repo/Users/dima/foo"), Some(IndexFilter::Repo("/Users/dima/foo".into())));
assert_eq!(parse_index_filter("/repo/tmp/my%20repo"), Some(IndexFilter::Repo("/tmp/my repo".into())));
assert_eq!(parse_index_filter("/repo/"), None);
}
#[test]
fn filtered_index_page_shows_only_matching_repo_and_opens_projects_tab() {
use super::index::{index_page_with_filter, IndexFilter};
let mut store = store_with_cast_proc(ProcStatus::Ok);
store.sessions.get_mut("castab").unwrap().repo = "/tmp/repo".into();
store.sessions.get_mut("castab").unwrap().ended_at = Some(5);
{
let mut other = store.sessions.get("castab").unwrap().clone();
other.id = "other1".into();
other.repo = "/tmp/other".into();
store.sessions.insert("other1".into(), other);
}
let html = index_page_with_filter(&store, Some(IndexFilter::Repo("/tmp/repo".into())));
assert!(html.contains(r#"class="tab active" data-tab="projects""#), "Projects tab active when filtered: {html}");
assert!(html.contains("filter-banner"), "filter banner present");
assert!(html.contains("Show all"), "clear-filter link");
assert!(html.contains("href=\"/projects\""), "Show all clears to /projects");
assert!(html.contains("castab"), "matching job shown");
assert!(!html.contains(">other1<") && !html.contains("/job/other1"), "other repo's job hidden");
assert!(html.contains(r#"class="repo-filter-link""#));
}
#[test]
fn review_round_six_fixes_hold() {
let store = store_with_cast_proc(ProcStatus::Ok);
let html = super::index_page(&store);
assert!(html.contains("lastTickSecs"), "monotonic tick guard ships");
assert!(html.contains("Retire any superseded socket"), "socket retirement ships");
assert!(html.contains(r#"<div id="images-runtimes" class="images-runtimes"></div>"#), "got: {html}");
assert!(html.contains(".seg-opt"), "segmented-control CSS ships");
assert!(html.contains("data-tip-running"), "live-ticking tip support ships");
assert!(html.contains("white-space: pre-line"), "multi-line tip CSS ships");
assert!(html.contains("function chipCountHtml"), "shared chip-count renderer ships");
}
#[test]
fn workflow_graph_renders_builtin_shapes() {
use crate::daemon::workflow::{WorkflowMeta, WorkflowNodeMeta};
fn skill_proc(index: usize, id: &str, harness: &str, status: ProcStatus) -> ProcRecord {
ProcRecord {
index,
kind: ProcKind::Skill,
label: format!("{harness}: {id}"),
status,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some(id.into()),
route: None,
result_path: None,
annotate_target: None,
harness: Some(harness.into()),
skill_name: Some(id.into()),
model: None,
started_at: Some(1),
elapsed: Some(1.0),
lines: vec![],
}
}
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"arith1".into(),
Session {
id: "arith1".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("arith".into()),
kind: Some("workflow".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![
skill_proc(0, "add", "claude", ProcStatus::Ok),
skill_proc(1, "multiply", "codex", ProcStatus::Ok),
skill_proc(2, "summarize", "grok", ProcStatus::Ok),
],
last_seen_at: 10,
client_connected: false,
run_pid: None,
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "add".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "multiply".into(),
proc_index: Some(1),
order: 1,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "summarize".into(),
proc_index: Some(2),
order: 2,
needs: vec!["add".into(), "multiply".into()],
conditional: false,
when_summary: None,
},
],
}),
parent_session: None,
},
);
let html = session_page(&store, "arith1").expect("page");
assert!(html.contains(r#"id="workflow-graph""#), "workflow card present");
assert!(html.contains("3 tasks · "), "summary starts with task count");
assert!(html.contains(r#">3 done</a>"#), "summary counts by status, not edge count");
assert!(html.contains(r#"class="wf-jump""#), "status counters are jump links");
assert!(html.contains("Jump to first done task"), "done counter links to a done node");
assert!(
html.contains("href=\"#task-add\"") || html.contains("href=\"#task-multiply\""),
"done jump targets a real node"
);
assert!(!html.contains("dependencies</p>"), "summary must not say N dependencies");
assert!(!html.contains(r#"class="workflow-summary dim">3 tasks · 2 dependencies"#));
assert!(html.contains(r#"data-workflow-step="add""#));
assert!(html.contains(r#"data-workflow-step="multiply""#));
assert!(html.contains(r#"data-workflow-step="summarize""#));
assert!(html.contains(r#"id="task-add""#));
assert!(html.contains("href=\"#task-summarize\""));
assert!(html.contains(r#"class="wf-bookend wf-start""#), "Start bookend on multi-node graphs too");
assert!(html.contains(r#"class="wf-bookend wf-finish""#), "Finish bookend");
let graph = html.split(r#"id="workflow-graph""#).nth(1).expect("graph card");
let graph = graph.split("</svg>").next().expect("svg");
assert_eq!(graph.matches("marker-end=\"url(#wf-arrow)\"").count(), 5);
assert!(html.contains(r#"class="wf-arrowhead""#), "open chevron arrowheads, not filled triangles");
let mut ends: Vec<(String, String)> = Vec::new();
for part in graph.split(r#"class="wf-edge" d=""#) {
if !part.contains(r#"marker-end="url(#wf-arrow)""#) {
continue;
}
let Some(d) = part.split('"').next() else {
continue;
};
let Some((x, y)) = d.rsplit_once(',') else {
continue;
};
let x = x.rsplit([' ', 'C']).next().unwrap_or(x);
ends.push((x.to_string(), y.to_string()));
}
assert_eq!(ends.len(), 5, "expected five edges, got {ends:?}");
let mut by_x: std::collections::BTreeMap<String, Vec<String>> = std::collections::BTreeMap::new();
for (x, y) in ends {
by_x.entry(x).or_default().push(y);
}
let fan_in = by_x.values().find(|ys| ys.len() == 2).expect("summarize fan-in pair missing");
assert_ne!(fan_in[0], fan_in[1], "fan-in edges must enter at different heights: {fan_in:?}");
assert!(html.contains(r#"<li class="wf-leg wf-leg-done""#));
assert!(!html.contains(r#"<li class="wf-leg wf-leg-running""#));
assert!(!html.contains(r#"<li class="wf-leg wf-leg-waiting""#));
assert!(!html.contains(r#"<li class="wf-leg wf-leg-failed""#));
assert!(!html.contains(r#"<li class="wf-leg wf-leg-stalled""#));
assert!(!html.contains(r#"<li class="wf-leg wf-leg-skipped""#));
let now = crate::daemon::paths::now_unix_secs();
store.sessions.insert(
"fruit1".into(),
Session {
id: "fruit1".into(),
started_at: now.saturating_sub(5),
ended_at: None,
profile: Some("fruits".into()),
kind: Some("workflow".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![
skill_proc(0, "categorize", "claude", ProcStatus::Ok),
skill_proc(1, "sort_fruits", "claude", ProcStatus::Waiting),
skill_proc(2, "sort_vegetables", "claude", ProcStatus::Waiting),
],
last_seen_at: now,
client_connected: true,
run_pid: Some(1),
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "categorize".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "sort_fruits".into(),
proc_index: Some(1),
order: 1,
needs: vec!["categorize".into()],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "sort_vegetables".into(),
proc_index: Some(2),
order: 2,
needs: vec!["categorize".into()],
conditional: false,
when_summary: None,
},
],
}),
parent_session: None,
},
);
let fruits = session_page(&store, "fruit1").expect("fruits");
assert!(
fruits.contains(r#">1 done</a>"#) && fruits.contains(r#">2 ready</a>"#),
"ready stays separate from waiting in the headline"
);
assert!(fruits.contains("data-tip="), "nodes carry instant tooltips");
assert!(fruits.contains("Ready — dependencies finished; not started yet"), "ready tip explains why the node is idle");
assert_eq!(
fruits
.split(r#"id="workflow-graph""#)
.nth(1)
.unwrap()
.split("</svg>")
.next()
.unwrap()
.matches("marker-end=\"url(#wf-arrow)\"")
.count(),
5 );
assert!(fruits.contains(r#"data-workflow-step="categorize""#));
store.sessions.insert(
"rev001".into(),
Session {
id: "rev001".into(),
started_at: 1,
ended_at: None,
profile: Some("code-review".into()),
kind: Some("workflow".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![
skill_proc(0, "probe_credentials", "claude", ProcStatus::Ok),
skill_proc(1, "review", "claude", ProcStatus::Skipped),
],
last_seen_at: 1,
client_connected: true,
run_pid: Some(1),
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "probe_credentials".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "review".into(),
proc_index: Some(1),
order: 1,
needs: vec!["probe_credentials".into()],
conditional: true,
when_summary: Some("Runs only if probe_credentials.ok = true".into()),
},
],
}),
parent_session: None,
},
);
let review = session_page(&store, "rev001").expect("review");
assert!(review.contains(r#"class="wf-gate""#), "gate marker");
assert!(review.contains(">when</span>"), "gate label is the word when, not a diamond");
assert!(
review.contains("Runs only when its gate passes"),
"gate tooltip is generic — no raw gate literals in the browser"
);
assert!(!review.contains("probe_credentials.ok"), "no gate operand leakage");
assert!(!review.contains("Runs only if"), "no authored when_summary in the page");
assert!(!review.contains("Conditional task"), "no cryptic Conditional task label");
assert!(!review.contains('◇'), "no diamond glyph");
assert!(review.contains(r#"wf-skipped"#));
assert_eq!(
review
.split(r#"id="workflow-graph""#)
.nth(1)
.unwrap()
.split("</svg>")
.next()
.unwrap()
.matches("marker-end=\"url(#wf-arrow)\"")
.count(),
3 );
let now = crate::daemon::paths::now_unix_secs();
store.sessions.insert(
"wait1".into(),
Session {
id: "wait1".into(),
started_at: now.saturating_sub(5),
ended_at: None,
profile: Some("arith".into()),
kind: Some("workflow".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![
skill_proc(0, "add", "claude", ProcStatus::Running),
skill_proc(1, "summarize", "grok", ProcStatus::Waiting),
],
last_seen_at: now,
client_connected: true,
run_pid: Some(1),
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "add".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "summarize".into(),
proc_index: Some(1),
order: 1,
needs: vec!["add".into()],
conditional: false,
when_summary: None,
},
],
}),
parent_session: None,
},
);
let waiting = session_page(&store, "wait1").expect("waiting");
assert!(waiting.contains("Waiting on:"), "waiting tip explains blockers");
assert!(waiting.contains("waiting on add"), "meta line names the blocker");
assert!(waiting.contains("margin-inline: auto"), "graph stage centers when it fits");
store.sessions.insert(
"stop1".into(),
Session {
id: "stop1".into(),
started_at: now.saturating_sub(30),
ended_at: Some(now),
profile: Some("demo-pr".into()),
kind: Some("definition".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![
{
let mut p = skill_proc(0, "cursor-build", "cursor", ProcStatus::Fail);
p.fail_reason = Some(crate::failure::reason::FORCE_STOPPED.into());
p.detail = Some("force-stopped from the session browser".into());
p
},
{
let mut p = skill_proc(1, "claude-run", "claude", ProcStatus::Fail);
p.fail_reason = Some(crate::failure::reason::HARNESS_NONZERO.into());
p
},
],
last_seen_at: now,
client_connected: false,
run_pid: None,
workflow: Some(WorkflowMeta {
nodes: vec![
WorkflowNodeMeta {
id: "cursor-build".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
},
WorkflowNodeMeta {
id: "claude-run".into(),
proc_index: Some(1),
order: 1,
needs: vec![],
conditional: false,
when_summary: None,
},
],
}),
parent_session: None,
},
);
let stopped = session_page(&store, "stop1").expect("force-stopped page");
let summary =
stopped.split(r#"class="workflow-summary dim">"#).nth(1).and_then(|s| s.split("</p>").next()).unwrap_or("?");
assert!(stopped.contains(r#"wf-force-stopped"#), "force-stopped node class; summary={summary}");
assert!(stopped.contains("Force-stopped"), "force-stopped label; summary={summary}");
assert!(summary.contains("force-stopped"), "summary counts force-stopped separately: {summary}");
assert!(summary.contains("failed"), "natural failure stays failed: {summary}");
assert!(stopped.contains("wf-leg-force-stopped"), "legend lists force-stopped");
let flat = store_with_cast_proc(ProcStatus::Ok);
let flat_html = session_page(&flat, "castab").expect("flat");
assert!(flat_html.contains(r#"id="workflow-graph""#), "every job with skills gets a graph");
assert!(flat_html.contains("Job graph"), "card title is Job graph");
assert!(flat_html.contains(r#"data-workflow-step="add""#) || flat_html.contains("wf-node"), "skill node present");
assert!(flat_html.contains(r#"class="wf-bookend wf-start""#), "Start bookend");
assert!(flat_html.contains(r#"class="wf-bookend wf-finish""#), "Finish bookend");
assert!(flat_html.contains("wf-start-play"), "play-triangle start glyph");
assert!(flat_html.contains("wf-finish-flag"), "checkered finish flag");
assert!(!flat_html.contains("wf-start-line"), "dashed race-line start glyph is gone");
assert!(!flat_html.contains("wf-bookend-label"), "bookends are icon-only");
let edge_count = flat_html.matches(r#"class="wf-edge""#).count();
assert!(edge_count >= 2, "single-run job still has Start→task and task→Finish edges: {edge_count}");
let flat_graph = flat_html.split(r#"id="workflow-graph""#).nth(1).expect("flat graph");
let flat_graph = flat_graph.split("</svg>").next().expect("flat svg");
let flat_edges: Vec<&str> = flat_graph
.split(r#"class="wf-edge" d=""#)
.skip(1)
.filter_map(|p| p.split('"').next())
.collect();
assert!(flat_edges.len() >= 2, "flat edges: {flat_edges:?}");
for d in &flat_edges {
assert!(d.contains(" L"), "same-row edge must be horizontal L, got {d}");
assert!(!d.contains(" C"), "same-row edge must not be a cubic S-curve, got {d}");
}
let js = live_client_js();
assert!(js.contains("function updateWorkflowGraph"));
assert!(js.contains("function activateWorkflowTask"));
assert!(js.contains("function initWorkflowGraph"));
assert!(js.contains("function wfLegendHtml"));
assert!(js.contains("function wfBuildGraphHtml"), "late graph creation without reload");
assert!(js.contains("function wfLayoutWithBookends"), "live graph mirrors Start/Finish");
assert!(js.contains("function wfNodeTip"), "useful node tooltips");
assert!(js.contains("function wfSummaryHtml"), "status counters are jump links");
assert!(js.contains("a.wf-jump"), "summary jump click wiring");
assert!(js.contains("history.pushState"), "task clicks push history");
assert!(js.contains("pendingWorkflowStep"), "pre-registration pending selection");
assert!(js.contains("Task details are not available yet"), "pending status copy");
}
#[test]
fn workflow_graph_bookends_runs_with_start_and_finish_terminals() {
use crate::daemon::workflow::{WorkflowMeta, WorkflowNodeMeta};
let mut store = Store::new(DaemonMode::Persistent, 7274, 1);
store.sessions.insert(
"solo1".into(),
Session {
id: "solo1".into(),
started_at: 1,
ended_at: Some(10),
profile: Some("solo".into()),
kind: Some("workflow".into()),
repo: "/tmp/r".into(),
branch: "main".into(),
skills: vec![],
procs: vec![ProcRecord {
index: 0,
kind: ProcKind::Skill,
label: "claude: add".into(),
status: ProcStatus::Ok,
note: None,
detail: None,
fail_reason: None,
container_name: None,
cast_path: None,
diff_path: None,
skill_source: Some("add".into()),
route: None,
result_path: None,
annotate_target: None,
harness: Some("claude".into()),
skill_name: Some("add".into()),
model: None,
started_at: Some(1),
elapsed: Some(1.0),
lines: vec![],
}],
last_seen_at: 10,
client_connected: false,
run_pid: None,
workflow: Some(WorkflowMeta {
nodes: vec![WorkflowNodeMeta {
id: "add".into(),
proc_index: Some(0),
order: 0,
needs: vec![],
conditional: false,
when_summary: None,
}],
}),
parent_session: None,
},
);
let html = session_page(&store, "solo1").expect("page");
let graph = html.split(r#"id="workflow-graph""#).nth(1).expect("graph card");
let svg = graph.split("</svg>").next().expect("svg");
assert!(graph.contains(r#"class="wf-bookend wf-start""#), "start bookend markup");
assert!(graph.contains(r#"class="wf-bookend wf-finish""#), "finish bookend markup");
assert!(graph.contains("wf-start-play"), "play-triangle start glyph");
assert!(graph.contains("wf-finish-flag"), "checkered finish flag");
assert!(!graph.contains(r#"wf-bookend wf-start" href"#), "start is not a link");
assert!(!graph.contains(r#"wf-bookend wf-finish" href"#), "finish is not a link");
assert!(!graph.contains(r#"wf-bookend wf-start" tabindex"#), "start never steals focus");
let edges = svg.split("</defs>").last().expect("edge paths");
assert_eq!(edges.matches("marker-end=\"url(#wf-arrow)\"").count(), 2, "start → run → finish is two arrows");
assert_eq!(edges.matches(" L").count(), 2, "same-row bookend edges are horizontal lines");
assert_eq!(edges.matches(" C").count(), 0, "no cubic S-curves in a one-run job");
assert!(html.contains(".wf-bookend"), "bookend styles shipped");
assert!(html.contains(".wf-start-play"), "play-triangle styles shipped");
assert!(html.contains(".wf-finish-flag"), "finish flag styles shipped");
let js = live_client_js();
assert!(js.contains("function wfBookendHtml"), "client JS re-render draws bookends");
assert!(js.contains("function wfLayoutWithBookends"), "client layout mirrors the bookends");
assert!(js.contains("wf-start-play"), "client start glyph class");
assert!(js.contains("wf-finish-flag"), "client finish flag class");
}