11#! /usr/bin/env bash
22# cockpit.test.sh — offline smoke test for cockpit.sh (issue #51, extended for
3- # Phase 2 live progress in issue #52, and Phase 3a serve/theme/filter in
4- # issue #69 ).
3+ # Phase 2 live progress in issue #52, Phase 3a serve/theme/filter in issue #69,
4+ # and the "Loop health" panel in issue #85 ).
55#
6- # Runs the generator against controlled FIXTURE issue/PR/events JSON (never
7- # live gh/network, and never the real event log — see cockpit.sh's
8- # --fixtures mode), then asserts the produced HTML contains every required
9- # section (issues-by-module with blocking relationships, PRs with review/CI
10- # badges, a routing table with a real `model:` value, a worktrees section,
11- # a live-progress panel deduped to each worker's latest phase, the default
12- # dark-theme marker) and that the blocking-relationship parser
13- # (`cockpit.sh --parse-blocking`) produces the expected edges for a known
14- # fixture body. Also exercises the "gh/network unavailable" degrade path via
15- # COCKPIT_GH_BIN, entirely offline (no real gh call, no .env), and a serve-mode
16- # smoke case (cockpit-serve.sh) against the SAME fixtures, over 127.0.0.1 only
17- # — no real network/gh either way.
6+ # Runs the generator against controlled FIXTURE issue/PR/events/loop-ticks
7+ # JSON (never live gh/network, and never the real event/tick logs — see
8+ # cockpit.sh's --fixtures mode), then asserts the produced HTML contains every
9+ # required section (issues-by-module with blocking relationships, PRs with
10+ # review/CI badges, a routing table with a real `model:` value, a worktrees
11+ # section, a live-progress panel deduped to each worker's latest phase, the
12+ # default dark-theme marker, and a loop-health panel showing the last tick +
13+ # cadence + a verdict history capped to the last COCKPIT_VERDICT_HISTORY_N
14+ # ticks (newest first) + a STALLED banner once a tick is overdue) and that
15+ # the blocking-relationship parser (`cockpit.sh
16+ # --parse-blocking`) produces the expected edges for a known fixture body.
17+ # Also exercises the "gh/network unavailable" / "no log at all" degrade paths
18+ # via COCKPIT_GH_BIN / missing CLAUDE_EVENTS_FILE / CLAUDE_TICKS_FILE, entirely
19+ # offline (no real gh call, no .env), and a serve-mode smoke case
20+ # (cockpit-serve.sh) against the SAME fixtures, over 127.0.0.1 only — no real
21+ # network/gh either way.
1822#
1923# Exit 0 on success, non-zero if any assertion fails. Runnable bare:
2024# bash .claude/scripts/cockpit.test.sh
@@ -121,9 +125,20 @@ cat > "$work/fixtures/events.jsonl" <<'EOF'
121125{"ts":"2026-01-01T00:02:00Z","role":"reviewer","model":"opus","task":"52b","phase":"reviewing","lens":"correctness","detail":""}
122126{"ts":"2026-01-01T00:03:00Z","role":"<script>xss()</script>\"","model":"sonnet","task":"52c","phase":"scoped","lens":"","detail":""}
123127EOF
128+ # Loop tick fixture (issue #85, "Loop health" panel): two ticks, file order =
129+ # chronological, so the LAST line (FAST/advance) is the most recent tick and
130+ # must render as "Last tick" while BOTH rows appear in the verdict history,
131+ # newest first.
132+ cat > " $work /fixtures/loop-ticks.jsonl" << 'EOF '
133+ {"ts":"2026-01-01T00:00:00Z","verdict":"action=none","cadence":"IDLE","action":"none","issue":"","pr":""}
134+ {"ts":"2026-01-01T00:15:00Z","verdict":"action=advance issue=7","cadence":"FAST","action":"advance","issue":"7","pr":""}
135+ EOF
124136
125137html=" $work /cockpit.html"
126- bash " $cockpit " --fixtures " $work /fixtures" " $html " > " $work /stdout.log" 2> " $work /stderr.log"
138+ # COCKPIT_NOW pins "now" to 90s after the last tick above -- inside FAST's
139+ # 120s stall threshold, so this run must NOT show the STALLED banner (that
140+ # path is exercised separately in section 2b below).
141+ COCKPIT_NOW=" 2026-01-01T00:16:30Z" bash " $cockpit " --fixtures " $work /fixtures" " $html " > " $work /stdout.log" 2> " $work /stderr.log"
127142rc=$?
128143check " generator exits 0 on fixture run" [ " $rc " -eq 0 ]
129144check " generator prints the output path" grep -qF " $html " " $work /stdout.log"
@@ -208,6 +223,87 @@ check "dark theme marker present by default" grep -qF 'data-theme="dark"' "$html
208223check " theme-toggle button present" grep -q ' id="theme-toggle"' " $html "
209224check " issue rows carry data-module for the client-side filter" grep -q ' data-module="module:harness"' " $html "
210225
226+ # Loop health panel (issue #85): last tick, cadence, verdict history newest
227+ # first, and NO stall banner (COCKPIT_NOW above is only 90s past the last
228+ # tick, inside FAST's 120s threshold).
229+ check " loop health section present" grep -q ' <section id="loop-health"' " $html "
230+ check " last tick's ts and verdict are rendered" grep -qF ' <code>2026-01-01T00:15:00Z</code> · verdict <code>action=advance issue=7</code>' " $html "
231+ check " current cadence (FAST) is rendered" grep -qF ' <span class="badge muted">FAST</span>' " $html "
232+ check " no STALLED banner when the last tick is within the cadence threshold" bash -c ' ! grep -q "STALLED" "$1"' _ " $html "
233+ check " verdict history renders BOTH ticks, newest first" node -e '
234+ const fs = require("fs");
235+ const html = fs.readFileSync(process.argv[1], "utf8");
236+ const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
237+ if (!m) throw new Error("loop-health section not found");
238+ const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
239+ const want = ["action=advance issue=7", "action=none"];
240+ if (JSON.stringify(rows) !== JSON.stringify(want)) {
241+ throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
242+ }
243+ ' " $html "
244+
245+ # ---------------------------------------------------------------------------
246+ # 2b. Loop health STALLED banner: a last tick far older than 2x its cadence's
247+ # expected interval must render the STALLED banner. Reuses the SAME
248+ # fixtures dir (issues/prs/events unrelated) but pins COCKPIT_NOW well
249+ # past the FAST tick's 120s threshold.
250+ # ---------------------------------------------------------------------------
251+ html_stalled=" $work /cockpit-stalled.html"
252+ COCKPIT_NOW=" 2026-01-01T01:00:00Z" bash " $cockpit " --fixtures " $work /fixtures" " $html_stalled " > /dev/null 2> " $work /stderr-stalled.log"
253+ check " STALLED banner renders once the last tick exceeds 2x its cadence interval" grep -qF ' STALLED — no tick in over 120s (cadence FAST)' " $html_stalled "
254+
255+ # ---------------------------------------------------------------------------
256+ # 2c. Verdict-history cap (review fix for issue #85): the panel must show only
257+ # the last N verdict lines, newest first -- NOT every retained tick (the
258+ # ticks file itself may hold up to LOOP_TICKS_MAX_LINES/2000 rows). Uses a
259+ # dedicated fixtures dir with 5 DISTINGUISHABLE ticks (unique issue= per
260+ # line, mirroring the loop-tick.test.sh rotation fix) and
261+ # COCKPIT_VERDICT_HISTORY_N=3 so the cap is exercised deterministically
262+ # without needing a huge fixture.
263+ # ---------------------------------------------------------------------------
264+ mkdir -p " $work /fixtures-history"
265+ echo " []" > " $work /fixtures-history/issues.json"
266+ echo " []" > " $work /fixtures-history/prs.json"
267+ : > " $work /fixtures-history/events.jsonl"
268+ cat > " $work /fixtures-history/loop-ticks.jsonl" << 'EOF '
269+ {"ts":"2026-01-01T00:00:00Z","verdict":"action=advance issue=1","cadence":"FAST","action":"advance","issue":"1","pr":""}
270+ {"ts":"2026-01-01T00:01:00Z","verdict":"action=advance issue=2","cadence":"FAST","action":"advance","issue":"2","pr":""}
271+ {"ts":"2026-01-01T00:02:00Z","verdict":"action=advance issue=3","cadence":"FAST","action":"advance","issue":"3","pr":""}
272+ {"ts":"2026-01-01T00:03:00Z","verdict":"action=advance issue=4","cadence":"FAST","action":"advance","issue":"4","pr":""}
273+ {"ts":"2026-01-01T00:04:00Z","verdict":"action=advance issue=5","cadence":"FAST","action":"advance","issue":"5","pr":""}
274+ EOF
275+ html_history=" $work /cockpit-history.html"
276+ COCKPIT_NOW=" 2026-01-01T00:04:30Z" COCKPIT_VERDICT_HISTORY_N=3 bash " $cockpit " --fixtures " $work /fixtures-history" " $html_history " > /dev/null 2> " $work /stderr-history.log"
277+ check " verdict-history cap: last tick is still the most recent (issue=5)" grep -qF ' <code>2026-01-01T00:04:00Z</code> · verdict <code>action=advance issue=5</code>' " $html_history "
278+ check " verdict-history cap: table renders exactly COCKPIT_VERDICT_HISTORY_N=3 rows, newest first" node -e '
279+ const fs = require("fs");
280+ const html = fs.readFileSync(process.argv[1], "utf8");
281+ const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
282+ if (!m) throw new Error("loop-health section not found");
283+ const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
284+ const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3"];
285+ if (JSON.stringify(rows) !== JSON.stringify(want)) {
286+ throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
287+ }
288+ ' " $html_history "
289+
290+ # Default (COCKPIT_VERDICT_HISTORY_N unset) with only 5 ticks retained must
291+ # still render all 5 -- the default cap (10) must not truncate BELOW what's
292+ # actually there.
293+ html_history_default=" $work /cockpit-history-default.html"
294+ COCKPIT_NOW=" 2026-01-01T00:04:30Z" bash " $cockpit " --fixtures " $work /fixtures-history" " $html_history_default " > /dev/null 2> " $work /stderr-history-default.log"
295+ check " verdict-history default cap (10) does not truncate a shorter (5-tick) history" node -e '
296+ const fs = require("fs");
297+ const html = fs.readFileSync(process.argv[1], "utf8");
298+ const m = html.match(/<section id="loop-health">[\s\S]*?<\/section>/);
299+ if (!m) throw new Error("loop-health section not found");
300+ const rows = [...m[0].matchAll(/<tr><td>([^<]*)<\/td><td><code>([^<]*)<\/code><\/td>/g)].map((r) => r[2]);
301+ const want = ["action=advance issue=5", "action=advance issue=4", "action=advance issue=3", "action=advance issue=2", "action=advance issue=1"];
302+ if (JSON.stringify(rows) !== JSON.stringify(want)) {
303+ throw new Error("got " + JSON.stringify(rows) + " want " + JSON.stringify(want));
304+ }
305+ ' " $html_history_default "
306+
211307# ---------------------------------------------------------------------------
212308# 3. GATES_FILE override is honored (self-host adapter), still with fixtures
213309# (no gh/network either way).
@@ -227,16 +323,19 @@ exit 1
227323EOF
228324chmod +x " $fake_gh "
229325html_unavail=" $work /cockpit-unavail.html"
230- # CLAUDE_EVENTS_FILE points at a guaranteed-missing path so this run is fully
231- # offline/deterministic (never touches the real, gitignored event log) and
232- # doubles as the "no events file at all" -> "no active workers" assertion.
233- COCKPIT_GH_BIN=" $fake_gh " CLAUDE_EVENTS_FILE=" $work /no-such-events.jsonl" bash " $cockpit " " $html_unavail " > /dev/null 2> " $work /stderr-unavail.log"
326+ # CLAUDE_EVENTS_FILE/CLAUDE_TICKS_FILE point at guaranteed-missing paths so
327+ # this run is fully offline/deterministic (never touches the real,
328+ # gitignored logs) and doubles as the "no log at all" degrade assertions for
329+ # both the live-progress panel ("no active workers") and the loop-health
330+ # panel ("loop not armed", issue #85) -- neither must crash the render.
331+ COCKPIT_GH_BIN=" $fake_gh " CLAUDE_EVENTS_FILE=" $work /no-such-events.jsonl" CLAUDE_TICKS_FILE=" $work /no-such-ticks.jsonl" bash " $cockpit " " $html_unavail " > /dev/null 2> " $work /stderr-unavail.log"
234332rc_unavail=$?
235333check " generator still exits 0 when gh is unavailable" [ " $rc_unavail " -eq 0 ]
236334check " issues section shows unavailable placeholder" grep -q ' <section id="issues"><h2>Open issues</h2><p class="unavailable">unavailable (gh/network)</p>' " $html_unavail "
237335check " PRs section shows unavailable placeholder" grep -q ' <section id="prs"><h2>Open PRs</h2><p class="unavailable">unavailable (gh/network)</p>' " $html_unavail "
238336check " routing/worktrees sections still render (no crash) despite gh failure" bash -c ' grep -q "routing" "$1" && grep -q "worktrees" "$1"' _ " $html_unavail "
239337check " missing events file renders 'no active workers' placeholder" grep -q ' <section id="live"><h2>Live worker progress</h2><p class="muted">no active workers</p>' " $html_unavail "
338+ check " missing loop-ticks log renders 'loop not armed' placeholder, no crash" grep -qF ' <section id="loop-health"><h2>Loop health</h2><p class="muted">loop not armed</p></section>' " $html_unavail "
240339
241340# ---------------------------------------------------------------------------
242341# 5. Serve mode (cockpit-serve.sh, issue #69): dashboard over HTTP + SSE live
0 commit comments