from pathlib import Path import re from tests.js_source_extract import extract_function 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 _locale_blocks_with_body(i18n_text: str): locale_blocks = re.findall( r"\n\s*(?:'(?P[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P[a-z]{2}(?:-[A-Z]{2})?))\s*:\s*\{(.*?)\n\s*\},", i18n_text, flags=re.S, ) return [(quoted or plain, body) for quoted, plain, body in locale_blocks] def test_kanban_has_native_sidebar_rail_and_mobile_tab(): assert 'data-panel="kanban"' in INDEX assert 'data-i18n-title="tab_kanban"' in INDEX # Allow either the legacy `switchPanel('kanban')` form or the rail-click-aware # `switchPanel('kanban',{fromRailClick:true})` form. The sidebar-collapse PR # added the second-arg opts to all rail buttons so the same-active-icon click # can toggle the sidebar; legacy callsites elsewhere may still use the bare form. assert ('onclick="switchPanel(\'kanban\')"' in INDEX or "onclick=\"switchPanel('kanban',{fromRailClick:true})\"" in INDEX), \ "kanban rail/mobile button must call switchPanel('kanban') (with or without fromRailClick opts)" assert 'data-label="Kanban"' in INDEX kanban_section = INDEX[INDEX.find('id="mainKanban"'):INDEX.find('id="mainWorkspaces"')] assert "' 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 '
' in INDEX assert 'id="kanbanBoard"' in INDEX assert 'id="kanbanTaskPreview"' in INDEX def test_switch_panel_lazy_loads_kanban_and_toggles_main_view(): main_view_panels = re.search(r"const MAIN_VIEW_PANELS = \[([^\]]+)\];", PANELS) assert main_view_panels, "MAIN_VIEW_PANELS should define main-view panels" assert "'kanban'" in main_view_panels.group(1) assert "MAIN_VIEW_PANELS.forEach(p => {" in PANELS assert "mainEl.classList.toggle('showing-' + p, nextPanel === p);" in PANELS assert "if (nextPanel === 'kanban') await loadKanban();" in PANELS assert "if (_currentPanel === 'kanban') await loadKanban();" in PANELS 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 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")] 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 # 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 assert "method: 'POST'" in PANELS assert "'/api/kanban/tasks/' + encodeURIComponent(taskId)" in PANELS assert "method: 'PATCH'" in PANELS assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/comments'" in PANELS assert "kanban-status-actions" in PANELS assert "kanban-comment-form" in PANELS 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("") + len("")] 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 # Assignee is now a backed by a datalist. assert "_kanbanPopulateAssigneeSelect" in body, ( "openKanbanCreate must populate the assignee populated from /api/profiles + board history, not a free-text input. Free-text invites typos that the dispatcher silently rejects (kanban_db.py:3567 "if not row[assignee]: skip"), and the dropdown makes the dispatcher contract explicit. """ # The modal markup uses , not . start = INDEX.rfind('<', 0, sel_idx) tag_open = INDEX[start:sel_idx + 60] assert tag_open.startswith(' element, got: {tag_open[:80]!r}" ) # Hint element exists and references the dispatcher claim contract. assert 'id="kanbanTaskModalAssigneeHint"' in INDEX hint_idx = INDEX.find('id="kanbanTaskModalAssigneeHint"') hint_block = INDEX[hint_idx:hint_idx + 400] assert "Hermes profile" in hint_block or "data-i18n=\"kanban_assignee_hint\"" in hint_block # The populator function loads from /api/profiles and groups options. pop_match = re.search( r"async function _kanbanPopulateAssigneeSelect\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL, ) assert pop_match, "_kanbanPopulateAssigneeSelect() not found" pop_body = pop_match.group(1) assert "_kanbanLoadProfileNames" in pop_body assert "` to # capture the full element (class= attribute precedes id= in the markup). btn_start = INDEX.rfind('', btn_idx) + len('') btn_html = INDEX[btn_start:btn_end] assert 'onclick="runKanbanDispatcher()"' in btn_html # Distinct visual class so users can tell it apart from the preview button. assert "kanban-run-dispatch-btn" in btn_html # 4. The sidebar bulk bar also has a Run dispatcher button alongside the # existing Preview button, so users in the filter pane can also run. bulk_idx = INDEX.find("kanbanBulkBar") bulk_html = INDEX[bulk_idx:bulk_idx + 1500] assert 'onclick="runKanbanDispatcher()"' in bulk_html, ( "Sidebar bulk bar must also expose Run dispatcher." ) # The dispatch result formatter exists and surfaces concrete numbers. assert "function _kanbanFormatDispatchResult" in PANELS fmt_match = re.search( r"function _kanbanFormatDispatchResult\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL, ) assert fmt_match fmt_body = fmt_match.group(1) for token in ("spawned", "skipped_unassigned", "skipped_nonspawnable", "promoted"): assert token in fmt_body, f"dispatch summary missing field: {token}" def test_kanban_dispatcher_inflight_guard_prevents_double_click_toast_confusion(): """Guard against concurrent dispatch invocations in both nudge and real run paths.""" assert "let _kanbanIsDispatching = false;" in PANELS assert "function _setKanbanDispatcherButtonsDisabled" in PANELS run_match = re.search(r"async function runKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL) assert run_match, "runKanbanDispatcher() not found" run_body = run_match.group(1) assert "_kanbanIsDispatching" in run_body, ( "runKanbanDispatcher() must check or set _kanbanIsDispatching to block concurrent execution." ) assert "finally" in run_body, "runKanbanDispatcher() must always clear _kanbanIsDispatching in finally." assert "_setKanbanDispatcherButtonsDisabled(true)" in run_body, ( "runKanbanDispatcher() should disable both dispatcher buttons while posting." ) assert "_setKanbanDispatcherButtonsDisabled(false)" in run_body, ( "runKanbanDispatcher() should re-enable dispatcher buttons when done." ) nudge_match = re.search(r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL) assert nudge_match, "nudgeKanbanDispatcher() not found" nudge_body = nudge_match.group(1) assert "_kanbanIsDispatching" in nudge_body, ( "nudgeKanbanDispatcher() should also respect the dispatch in-flight guard." ) assert "finally" in nudge_body, "nudgeKanbanDispatcher() should always clear guard in finally." assert 'kanban-run-dispatch-btn' in INDEX assert 'kanban-nudge-dispatch-btn' in INDEX assert 'btnKanbanRunDispatcher' in INDEX assert 'btnKanbanPreviewDispatcher' in INDEX def test_kanban_dispatcher_no_longer_blocks_default_board(): """Pin removal of the previous null-board guard in runKanbanDispatcher().""" run_source = extract_function(PANELS, "runKanbanDispatcher", prefix="async function") assert "if (!_kanbanCurrentBoard)" not in run_source, ( "runKanbanDispatcher() must not block when _kanbanCurrentBoard is null; " "default board should dispatch through a board-less path." ) 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 = _locale_blocks_with_body(I18N) assert len(locale_blocks) >= 9 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", "kanban_comments_count", "kanban_events_count", "kanban_links", "kanban_runs_count", "kanban_no_comments", "kanban_no_events", "kanban_no_runs", "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 == [] def test_kanban_modal_locale_parity(): """Parity check for modal-facing Kanban i18n keys. Any locale that already contains modal-facing Kanban strings should include the same set of modal vocabulary so new additions don't regress into locale gaps. """ locale_blocks = _locale_blocks_with_body(I18N) modal_keys = [ "kanban_title", "kanban_description", "kanban_description_placeholder", "kanban_status", "kanban_assignee", "kanban_assignee_placeholder", "kanban_tenant", "kanban_tenant_placeholder", "kanban_priority", "kanban_priority_hint", "kanban_title_required", "kanban_status_original_hint", ] anchor_key = "kanban_status" missing = [ f"{locale}:{key}" for locale, body in locale_blocks if re.search(rf"\b{re.escape(anchor_key)}\s*:", body) is not None for key in modal_keys if re.search(rf"\b{re.escape(key)}\s*:", body) is None ] assert missing == [] 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 # 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" assert "prompt(" not in PANELS assert "confirm(" not in PANELS def test_kanban_dashboard_parity_i18n_keys_exist(): locale_blocks = _locale_blocks_with_body(I18N) required_keys = [ "kanban_only_mine", "kanban_bulk_action", "kanban_nudge_dispatcher", "kanban_work_queue_hint", "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 == [] 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_dragging_card_does_not_open_detail_on_drop_click(): """Regression: drag/drop should move a card without opening task detail.""" assert "function _kanbanSuppressNextCardClick" in PANELS assert "let _kanbanSuppressCardClickUntil" in PANELS assert "function openKanbanCard" in PANELS assert "function finishKanbanDrag" in PANELS drag_fn = re.search(r"function dragKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL) assert drag_fn, "dragKanbanTask() not found" assert "_kanbanSuppressNextCardClick" in drag_fn.group(1), ( "drag start must arm the click suppressor so the trailing click after " "drop cannot open the task detail pane" ) finish_fn = re.search(r"function finishKanbanDrag\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL) assert finish_fn, "finishKanbanDrag() not found" assert "_kanbanSuppressNextCardClick" in finish_fn.group(1), ( "drag end must refresh the suppressor window before browsers emit a " "trailing synthetic click" ) drop_fn = re.search(r"async function dropKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL) assert drop_fn, "dropKanbanTask() not found" drop_body = drop_fn.group(1) assert "_kanbanSuppressNextCardClick" in drop_body assert "event.stopPropagation()" in drop_body assert "updateKanbanTask(taskId, {status}, {openDetail: false})" in drop_body, ( "drag/drop status updates must refresh the board without opening the task detail" ) update_fn = re.search(r"async function updateKanbanTask\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL) assert update_fn, "updateKanbanTask() not found" update_body = update_fn.group(1) assert "const openDetail = !opts || opts.openDetail !== false;" in update_body assert "if (openDetail) await loadKanbanTask" in update_body card_template = re.search(r"return `
`;", PANELS, re.DOTALL) assert card_template, "Kanban card template not found" card_html = card_template.group(0) assert "ondragend=\"finishKanbanDrag(event)\"" in card_html assert "onclick=\"return openKanbanCard(event," in card_html assert "onclick=\"loadKanbanTask" not in card_html, ( "Kanban cards must not call loadKanbanTask directly from onclick; " "drag/drop needs a guarded click path" ) open_fn = re.search(r"function openKanbanCard\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL) assert open_fn, "openKanbanCard() not found" open_body = open_fn.group(1) for token in ( "Date.now()", "_kanbanSuppressCardClickUntil", "preventDefault", "stopPropagation", "loadKanbanTask", ): assert token in open_body 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 assert "kanban_card_start" not in I18N assert '' 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 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 = _locale_blocks_with_body(I18N) required_keys = ["kanban_lanes_by_profile", "kanban_card_complete", "kanban_card_archive", "kanban_unassigned", "kanban_work_queue_hint"] 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 => ({'&':'&','<':'<','>':'>','\"':'"',"'":'''}[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" ) # ── 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_icon_column_clamps_long_labels(): """Regression for #2458: board metadata may use a short text label in the icon/color slot. The menu must keep that label inside its own column instead of letting it overlap the board title and count badge. """ rule = re.search( r"\.kanban-board-switcher-item-icon\{(?P.*?)\}", STYLE, flags=re.S, ) assert rule, "missing .kanban-board-switcher-item-icon CSS rule" compact = re.sub(r"\s+", "", rule.group("body")) for required in ( "overflow:hidden", "text-overflow:ellipsis", "white-space:nowrap", "max-width:7.5rem", "min-width:18px", ): assert required in compact 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//switch assert "/api/kanban/boards/' + encodeURIComponent" in PANELS assert "/switch'" in PANELS # PATCH /boards/ assert "method: 'PATCH'" in PANELS # DELETE /boards/ assert "method: 'DELETE'" in PANELS def test_kanban_board_param_is_plumbed_into_api_calls(): """Every existing kanban endpoint call must carry ?board= 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_profile_assignee_cache_has_invalidation_path(): """Kanban assignee suggestions should stay aligned with profile mutations. The cache in _kanbanLoadProfileNames() can become stale when profiles are created or deleted in the same session. This adds an explicit invalidation path and a short TTL so modal opens recover from same-session mutations and cross-tab/CLI changes. """ assert "_KANBAN_PROFILE_NAMES_CACHE_TTL_MS" in PANELS assert "_kanbanProfileNamesCacheAt" in PANELS assert "_invalidateKanbanProfileCache" in PANELS load_start = PANELS.find("async function _kanbanLoadProfileNames(){") assert load_start != -1, "Missing _kanbanLoadProfileNames() declaration" load_end = PANELS.find("\n}\n\nasync function _kanbanPopulateAssigneeSelect", load_start) if load_end == -1: load_end = PANELS.find("\n}\n\nfunction openKanbanCreate", load_start) load_body = PANELS[load_start:load_end] if load_end != -1 else PANELS[load_start:load_start + 2200] assert "Date.now() - _kanbanProfileNamesCacheAt" in load_body assert "_kanbanProfileNamesCacheAt = Date.now()" in load_body save_start = PANELS.find("async function saveProfileForm(){") assert save_start != -1, "Missing saveProfileForm() declaration" save_end = PANELS.find("\n}\n\n// Back-compat", save_start) save_body = PANELS[save_start:save_end if save_end != -1 else save_start + 2000] assert "_invalidateKanbanProfileCache();" in save_body, ( "Profile create flow should invalidate Kanban assignee cache after success." ) delete_start = PANELS.find("async function deleteProfile(name) {") assert delete_start != -1, "Missing deleteProfile() declaration" delete_end = PANELS.find("\n\n// ── Memory panel", delete_start) delete_body = PANELS[delete_start:delete_end if delete_end != -1 else delete_start + 1300] assert "_invalidateKanbanProfileCache();" in delete_body, ( "Profile delete flow should invalidate Kanban assignee cache after success." ) ui_delete_start = PANELS.find("async function deleteCurrentProfile(){") assert ui_delete_start != -1, "Missing deleteCurrentProfile() declaration" ui_delete_end = PANELS.find("\n\nfunction renderProfileDropdown", ui_delete_start) ui_delete_body = PANELS[ui_delete_start:ui_delete_end if ui_delete_end != -1 else ui_delete_start + 1300] assert "_invalidateKanbanProfileCache();" in ui_delete_body, ( "Profile detail delete flow (deleteCurrentProfile) should invalidate Kanban assignee cache after success." ) 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 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 fn_source = extract_function(PANELS, "_kanbanSafeColor") script = """ const fnSource = __FN__; const ctx = {}; new Function('out', fnSource + '; 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)); """.replace("__FN__", json.dumps(fn_source)) 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 def test_kanban_locale_parity(): """Every kanban_* i18n key in the English locale must exist in all non-English locale blocks. The kanban panel has its own set of ~86 keys (kanban_board, kanban_task, …) that are rendered via t() — a missing key silently falls back to English, which is acceptable for content keys but confusing for UI labels the user expects to see translated. This test catches regressions where a new kanban key is added to the English block but not to one or more locale blocks. Pattern borrowed from test_lineage_segment_locale_keys_are_defined_for_sidebar_locales in test_session_lineage_collapse.py. Refs: #1973 """ locale_blocks = _locale_blocks_with_body(I18N) assert locale_blocks, "No locale blocks found in i18n.js" # Collect the kanban_* keys from the English block. en_name = "en" en_body = None for name, body in locale_blocks: if name == en_name: en_body = body break assert en_body is not None, "English locale block not found" en_keys = set(re.findall(r"(kanban_\w+)\s*:", en_body)) assert en_keys, "No kanban_* keys found in English locale" # Verify each non-English locale has the same set. failures = [] for name, body in locale_blocks: if name == en_name: continue loc_keys = set(re.findall(r"(kanban_\w+)\s*:", body)) missing = en_keys - loc_keys extra = loc_keys - en_keys if missing: failures.append(f"{name}: missing {sorted(missing)}") if extra: failures.append(f"{name}: extra {sorted(extra)}") assert not failures, ( "Kanban i18n key parity violations:\n" + "\n".join(failures) ) def test_kanban_profile_lanes_explicitly_render_unassigned_lane(): """Regression: unassigned Ready tasks must not disappear when the board is grouped by profile/lane. Mobile users should see an explicit Unassigned lane via a stable internal key instead of needing tasks assigned to `default` for visibility. """ assert "function _kanbanLaneNames" in PANELS assert "function _kanbanRenderProfileLanes" in PANELS assert "KANBAN_UNASSIGNED_LANE" in PANELS assert "function _kanbanLaneKey" in PANELS assert "function _kanbanLaneLabel" in PANELS assert "kanban_unassigned" in PANELS # Lane key helper returns the constant for tasks without an assignee. assert "KANBAN_UNASSIGNED_LANE" in PANELS # Lane label helper converts the constant back to a translated string. assert "t('kanban_unassigned')" in PANELS # _kanbanLaneNames uses _kanbanLaneKey to build the set, not raw assignee. lane_names_match = re.search( r"function _kanbanLaneNames\(columns\)\{(.*?)\nfunction ", PANELS, re.DOTALL, ) assert lane_names_match, "_kanbanLaneNames() not found" lane_names_body = lane_names_match.group(1) assert "_kanbanLaneKey(task)" in lane_names_body # Unassigned lane is appended last (after assigned lanes). assert "has(KANBAN_UNASSIGNED_LANE)" in lane_names_body # _kanbanRenderProfileLanes uses _kanbanLaneKey for filtering and # _kanbanLaneLabel for display, and emits the unassigned CSS class. render_match = re.search( r"function _kanbanRenderProfileLanes\(columns\)\{(.*?)\nfunction ", PANELS, re.DOTALL, ) assert render_match, "_kanbanRenderProfileLanes() not found" render_body = render_match.group(1) assert "_kanbanLaneNames(columns)" in render_body assert "_kanbanLaneKey(task)" in render_body assert "_kanbanLaneLabel(lane)" in render_body assert "kanban-profile-lane-unassigned" in render_body assert "kanban-profile-lane" in render_body def test_kanban_hidden_by_filters_ux(): """When all tasks are filtered by the text search but unfiltered data exists, the board should show an explanation and a Clear filters button instead of a generic 'No Kanban data' empty state. """ # The helper functions exist. assert "function _kanbanHiddenByFiltersHtml" in PANELS assert "function clearKanbanFilters" in PANELS # The empty-board branch checks unfiltered total. render_match = re.search( r"function _kanbanRenderBoard\(\)\{(.*?)\nfunction ", PANELS, re.DOTALL, ) assert render_match, "_kanbanRenderBoard() not found" render_body = render_match.group(1) assert "_kanbanHiddenByFiltersHtml" in render_body assert "unfilteredTotal" in render_body # The hidden-html helper references the i18n keys. hidden_match = re.search( r"function _kanbanHiddenByFiltersHtml\(\)\{(.*?)\n\}", PANELS, re.DOTALL, ) assert hidden_match, "_kanbanHiddenByFiltersHtml() not found" hidden_body = hidden_match.group(1) assert "kanban_tasks_hidden_by_filters" in hidden_body assert "kanban_clear_filters" in hidden_body assert "clearKanbanFilters()" in hidden_body # clearKanbanFilters() resets all filter inputs and reloads. clear_match = re.search( r"function clearKanbanFilters\(\)\{(.*?)\n\}", PANELS, re.DOTALL, ) assert clear_match, "clearKanbanFilters() not found" clear_body = clear_match.group(1) assert "kanbanSearch" in clear_body assert "kanbanAssigneeFilter" in clear_body assert "kanbanTenantFilter" in clear_body assert "loadKanban(true)" in clear_body # i18n keys exist in every locale. locale_blocks = _locale_blocks_with_body(I18N) for key in ("kanban_tasks_hidden_by_filters", "kanban_clear_filters"): missing = [ locale for locale, body in locale_blocks if re.search(rf"\b{re.escape(key)}\s*:", body) is None ] assert missing == [], f"i18n key '{key}' missing from locales: {missing}" def test_kanban_unassigned_lane_in_sidebar_meta(): """Sidebar task list must show 'unassigned' label for tasks without an assignee, not silently omit the field. """ meta_match = re.search( r"function _kanbanTaskMeta\(task\)\{(.*?)\n\}", PANELS, re.DOTALL, ) assert meta_match, "_kanbanTaskMeta() not found" meta_body = meta_match.group(1) # Must emit unassigned label when task.assignee is falsy. assert "t('kanban_unassigned')" in meta_body