Files
hermes-agent/tests/plugins/test_kanban_dashboard_plugin.py

630 lines
22 KiB
Python
Raw Normal View History

feat(kanban): dashboard plugin — Linear/Fusion-style board UI Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core changes — uses the standard dashboard plugin contract (manifest.json + dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'. What the tab gives you: - One column per kanban status (todo / ready / running / blocked / done; archived behind a toggle), column counts, coloured status dots. - Cards with id, title, priority badge, tenant tag, assignee, comment/link counts, 'created N ago'. - HTML5 drag-drop between columns — status change routes through the same kanban_db code the CLI /kanban verbs use, so the three surfaces (CLI, gateway, dashboard) can never drift. - Inline create per-column (title, assignee, priority). - Side drawer on card click: description, status action row (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, last 20 events. - Toolbar: search, tenant filter, assignee filter, show-archived, nudge-dispatcher (skip the 60s wait), refresh. - Live updates via WebSocket tailing task_events — the board reflects CLI or gateway actions in real time. REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id, POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links, DELETE /links, POST /dispatch, WS /events. Every handler is a thin wrapper around kanban_db — no new business logic. Visually theme-aware: the plugin CSS reads only --color-*, --radius, --font-mono etc. so it reskins with whichever dashboard theme is active. Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests): - empty board shape - create + appears in ready column with tenant/assignee rollups - tenant filter - detail includes parents/children/events - 404 on unknown task - PATCH status: complete / block / unblock / ready drag-drop / running - PATCH reassign, priority, edit, invalid-status rejection - POST comment (plus empty-body rejection) - POST link + DELETE link + cycle rejection - POST dispatch (dry run) All 76 kanban tests pass under scripts/run_tests.sh. Docs: website/docs/user-guide/features/kanban.md gains a full 'Dashboard (GUI)' section covering install, architecture, REST surface, live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
"""Tests for the Kanban dashboard plugin backend (plugins/kanban/dashboard/plugin_api.py).
The plugin mounts as /api/plugins/kanban/ inside the dashboard's FastAPI app,
but here we attach its router to a bare FastAPI instance so we can test the
REST surface without spinning up the whole dashboard.
"""
from __future__ import annotations
import importlib.util
feat(kanban): bulk ops, drawer edit, dep editor, markdown, touch, config The dashboard plugin gets the last layer of features that turn it from a 'usable read surface with drag-drop' into a 'full kanban UI' — no more 'drop to CLI to do X' moments from inside the tab. Plugin backend - POST /tasks/bulk — apply the same patch (status / archive / assignee / priority) to every id in the request body. Each id runs independently: one bad id reports {ok: false, error: ...} without aborting siblings. Status transitions that aren't legal for the current state are surfaced per-id ('transition to done refused'). Used by the multi-select bulk action bar. - GET /config — returns the dashboard.kanban section of config.yaml (default_tenant, lane_by_profile, include_archived_by_default, render_markdown) with sensible defaults when the section is absent. Loaded once by the SPA to preselect filters and toggle markdown rendering. - _conn() helper — every handler now goes through it, calling kanban_db.init_db() (idempotent) before every connection. Fresh installs work whether the first hit is GET /board, POST /tasks, or any other endpoint — no more 'no such table: tasks' when the CLI or a script hits the plugin before the dashboard has ever loaded. Plugin UI (plugin bundle, +~12 KB) - Multi-select: per-card checkbox; shift/ctrl-click also toggles without opening the drawer. A BulkActionBar appears above the columns with batch → ready / complete / archive / reassign (profile dropdown + unassign option). Destructive batches confirm first. Partial failures from the backend are surfaced inline. - Drawer inline editing: - Click the title → TitleEditor swaps in an input, Enter saves, Escape cancels. - Click the Assignee meta row → AssigneeEditor input (empty string unassigns). - Click the Priority meta row → PriorityEditor numeric input. - New 'edit' button on Description → full-width textarea; Save / Cancel switch back to rendered view. - Dependency editor: chip list of parents + children with per-chip × button (calls DELETE /links). Add-parent / add-child dropdowns filter out self + already-linked tasks so you cannot re-add a duplicate edge or a self-loop. Cycle rejections from the server surface cleanly via the existing error banner. - Parent selection in InlineCreate: new dropdown listing every task on the board ('{id} — {title}') — picking one sends parents=[id] with the create payload, so the task lands in todo (or triage if created from the Triage column) with the dependency wired up. - Safe markdown rendering for description, comment bodies, and result. A small in-bundle renderer handles headings, bold, italic, inline code, fenced code, bullet lists, and http(s)/mailto links. Every substitution runs on HTML-escaped input (no raw HTML), links get target=_blank + rel=noopener,noreferrer. Disabled by config key dashboard.kanban.render_markdown=false (falls back to <pre>). - Touch drag-drop: attachTouchDrag() installs a pointerdown handler that spawns a drag proxy, tracks elementFromPoint under the finger, and dispatches a hermes-kanban:drop CustomEvent on the column when released. Desktop continues to use native HTML5 DnD. Columns listen for both. - ErrorBoundary already present from the prior commit catches any renderer throw; markdown escape + touch-proxy cleanup both have their own try/finally. Tests (tests/plugins/test_kanban_dashboard_plugin.py — 90/90 pass) - bulk_status_ready: 3 tasks blocked, batch → ready, all move - bulk_archive hides all ids from default board - bulk_reassign changes every assignee - bulk_unassign_via_empty_string sets assignee back to None - bulk_partial_failure_doesnt_abort_siblings: bogus id in middle, good siblings still get priority=7 - bulk_empty_ids_400 - config_returns_defaults_when_section_missing - config_reads_dashboard_kanban_section (writes config.yaml, verifies every key round-trips) Live smoke (real FastAPI app + isolated HERMES_HOME): - /config without section returns defaults - /config with dashboard.kanban section returns the configured values - POST /tasks as the first-ever request (no prior /board) succeeds — auto-init handles it - Link add + remove via POST /links + DELETE /links round-trip - Bulk priority bump on 2 ids, both get priority=5 - Bulk archive hides ids from default board - PATCH {title, body} updates the task, markdown source survives the round trip - POST /tasks {triage: true, parents: [id]} lands in triage, not todo - Bulk partial: 2 good + 1 bogus returns per-id outcome Docs (website/docs/user-guide/features/kanban.md) - 'What the plugin gives you' rewritten to reflect bulk, drawer edit, dep editor, parent-on-create, markdown, touch drag-drop. - New 'Dashboard config' subsection with a YAML example for dashboard.kanban.*. - REST table gains /tasks/bulk and /config rows.
2026-04-26 12:36:23 -07:00
import os
feat(kanban): dashboard plugin — Linear/Fusion-style board UI Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core changes — uses the standard dashboard plugin contract (manifest.json + dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'. What the tab gives you: - One column per kanban status (todo / ready / running / blocked / done; archived behind a toggle), column counts, coloured status dots. - Cards with id, title, priority badge, tenant tag, assignee, comment/link counts, 'created N ago'. - HTML5 drag-drop between columns — status change routes through the same kanban_db code the CLI /kanban verbs use, so the three surfaces (CLI, gateway, dashboard) can never drift. - Inline create per-column (title, assignee, priority). - Side drawer on card click: description, status action row (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, last 20 events. - Toolbar: search, tenant filter, assignee filter, show-archived, nudge-dispatcher (skip the 60s wait), refresh. - Live updates via WebSocket tailing task_events — the board reflects CLI or gateway actions in real time. REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id, POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links, DELETE /links, POST /dispatch, WS /events. Every handler is a thin wrapper around kanban_db — no new business logic. Visually theme-aware: the plugin CSS reads only --color-*, --radius, --font-mono etc. so it reskins with whichever dashboard theme is active. Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests): - empty board shape - create + appears in ready column with tenant/assignee rollups - tenant filter - detail includes parents/children/events - 404 on unknown task - PATCH status: complete / block / unblock / ready drag-drop / running - PATCH reassign, priority, edit, invalid-status rejection - POST comment (plus empty-body rejection) - POST link + DELETE link + cycle rejection - POST dispatch (dry run) All 76 kanban tests pass under scripts/run_tests.sh. Docs: website/docs/user-guide/features/kanban.md gains a full 'Dashboard (GUI)' section covering install, architecture, REST surface, live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
import sys
import time
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from hermes_cli import kanban_db as kb
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _load_plugin_router():
"""Dynamically load plugins/kanban/dashboard/plugin_api.py and return its router."""
repo_root = Path(__file__).resolve().parents[2]
plugin_file = repo_root / "plugins" / "kanban" / "dashboard" / "plugin_api.py"
assert plugin_file.exists(), f"plugin file missing: {plugin_file}"
spec = importlib.util.spec_from_file_location(
"hermes_dashboard_plugin_kanban_test", plugin_file,
)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = mod
spec.loader.exec_module(mod)
return mod.router
@pytest.fixture
def kanban_home(tmp_path, monkeypatch):
"""Isolated HERMES_HOME with an empty kanban DB."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
return home
@pytest.fixture
def client(kanban_home):
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
return TestClient(app)
# ---------------------------------------------------------------------------
# GET /board on an empty DB
# ---------------------------------------------------------------------------
def test_board_empty(client):
r = client.get("/api/plugins/kanban/board")
assert r.status_code == 200
data = r.json()
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish Follows up on the initial dashboard plugin with the items called out during self-review — ships the GUI-reality claims the PR body made, closes the WebSocket auth gap, and lands the 'Triage' status the design spec's Fusion-style screenshot leads with. Kernel changes - kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a CHECK constraint so no schema migration is needed. - create_task(triage=True) forces the initial status to 'triage' regardless of parents, and parent ids are still validated so the eventual link rows don't dangle. recompute_ready() only promotes 'todo' -> 'ready', so triage tasks are naturally isolated from the dispatcher pipeline. - hermes kanban create gains --triage. Patterns table (docs) gains P9 'Triage specifier'. Plugin backend (plugins/kanban/dashboard/plugin_api.py) - GET /board now auto-init's kanban.db on first read (idempotent). A fresh install shows an empty board instead of 'failed to load'. - GET /board returns a new 'progress' field per task — {done, total} of child-task completion, or None if the task has no children. - BOARD_COLUMNS prepends 'triage'. - POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts {status: 'triage'}. - WebSocket /events now requires ?token=<session_token> as a query param — browsers can't set Authorization on a WS upgrade, so this matches the pattern the in-browser PTY bridge uses. Constant-time compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test contexts (no dashboard module) the check no-ops so the tail loop stays testable. Security boundary documented in the module header and in website/docs/user-guide/features/kanban.md. Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css) - Adds the Triage column (lilac dot) with helper text 'Raw ideas — a specifier will flesh out the spec'. Inline-create from the Triage column parks new tasks in triage. - Status action row in the drawer gains '→ triage'. - Progress pill (N/M) on cards that have children. Full-complete state tints the pill green. - 'Lanes by profile' toolbar toggle — sub-groups the Running column by assignee so you see at a glance which specialist is busy on what. - Destructive status moves (done / archived / blocked) via drag-drop OR via the drawer action row now prompt for confirmation. - Escape closes the drawer. - Live-update reloads are debounced (250ms) so a burst of task_events triggers one refetch, not N. - WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__. - WebSocket reconnect uses exponential backoff capped at 30s, not a fixed 1.5s spin loop, and surfaces a user-visible error on code-1008 (auth rejected) instead of reconnecting forever. - ErrorBoundary wraps the page — a bad card render shows a 'rendering error, reload view' card instead of crashing the tab. Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21) - empty-board shape now asserts all 6 columns including 'triage' - create_triage_lands_in_triage_column - triage_task_not_promoted_to_ready (dispatcher bypasses triage) - patch_status_triage_works (both into triage and out of it) - board_progress_rollup (0/2 -> 1/2 -> childless cards = None) - board_auto_initializes_missing_db - ws_events_rejects_when_token_required (three sub-assertions: missing → 1008, wrong → 1008, correct → handshake accepted) All 82 kanban tests pass under scripts/run_tests.sh. Docs - kanban.md 'What the plugin gives you' fully rewritten to match shipped reality (triage, progress pill, assignee lanes, destructive-confirm, Escape-close, debounce). - New 'Security model' subsection documents the explicit-plugin- route-bypass, the WS token requirement, and the --host 0.0.0.0 warning; also notes that kanban.db is profile-agnostic on purpose (the coordination primitive) so cross-profile visibility is expected. - CLI command reference shows --triage. - Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
# All canonical columns present (triage + the rest), each empty.
feat(kanban): dashboard plugin — Linear/Fusion-style board UI Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core changes — uses the standard dashboard plugin contract (manifest.json + dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'. What the tab gives you: - One column per kanban status (todo / ready / running / blocked / done; archived behind a toggle), column counts, coloured status dots. - Cards with id, title, priority badge, tenant tag, assignee, comment/link counts, 'created N ago'. - HTML5 drag-drop between columns — status change routes through the same kanban_db code the CLI /kanban verbs use, so the three surfaces (CLI, gateway, dashboard) can never drift. - Inline create per-column (title, assignee, priority). - Side drawer on card click: description, status action row (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, last 20 events. - Toolbar: search, tenant filter, assignee filter, show-archived, nudge-dispatcher (skip the 60s wait), refresh. - Live updates via WebSocket tailing task_events — the board reflects CLI or gateway actions in real time. REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id, POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links, DELETE /links, POST /dispatch, WS /events. Every handler is a thin wrapper around kanban_db — no new business logic. Visually theme-aware: the plugin CSS reads only --color-*, --radius, --font-mono etc. so it reskins with whichever dashboard theme is active. Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests): - empty board shape - create + appears in ready column with tenant/assignee rollups - tenant filter - detail includes parents/children/events - 404 on unknown task - PATCH status: complete / block / unblock / ready drag-drop / running - PATCH reassign, priority, edit, invalid-status rejection - POST comment (plus empty-body rejection) - POST link + DELETE link + cycle rejection - POST dispatch (dry run) All 76 kanban tests pass under scripts/run_tests.sh. Docs: website/docs/user-guide/features/kanban.md gains a full 'Dashboard (GUI)' section covering install, architecture, REST surface, live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
names = [c["name"] for c in data["columns"]]
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish Follows up on the initial dashboard plugin with the items called out during self-review — ships the GUI-reality claims the PR body made, closes the WebSocket auth gap, and lands the 'Triage' status the design spec's Fusion-style screenshot leads with. Kernel changes - kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a CHECK constraint so no schema migration is needed. - create_task(triage=True) forces the initial status to 'triage' regardless of parents, and parent ids are still validated so the eventual link rows don't dangle. recompute_ready() only promotes 'todo' -> 'ready', so triage tasks are naturally isolated from the dispatcher pipeline. - hermes kanban create gains --triage. Patterns table (docs) gains P9 'Triage specifier'. Plugin backend (plugins/kanban/dashboard/plugin_api.py) - GET /board now auto-init's kanban.db on first read (idempotent). A fresh install shows an empty board instead of 'failed to load'. - GET /board returns a new 'progress' field per task — {done, total} of child-task completion, or None if the task has no children. - BOARD_COLUMNS prepends 'triage'. - POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts {status: 'triage'}. - WebSocket /events now requires ?token=<session_token> as a query param — browsers can't set Authorization on a WS upgrade, so this matches the pattern the in-browser PTY bridge uses. Constant-time compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test contexts (no dashboard module) the check no-ops so the tail loop stays testable. Security boundary documented in the module header and in website/docs/user-guide/features/kanban.md. Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css) - Adds the Triage column (lilac dot) with helper text 'Raw ideas — a specifier will flesh out the spec'. Inline-create from the Triage column parks new tasks in triage. - Status action row in the drawer gains '→ triage'. - Progress pill (N/M) on cards that have children. Full-complete state tints the pill green. - 'Lanes by profile' toolbar toggle — sub-groups the Running column by assignee so you see at a glance which specialist is busy on what. - Destructive status moves (done / archived / blocked) via drag-drop OR via the drawer action row now prompt for confirmation. - Escape closes the drawer. - Live-update reloads are debounced (250ms) so a burst of task_events triggers one refetch, not N. - WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__. - WebSocket reconnect uses exponential backoff capped at 30s, not a fixed 1.5s spin loop, and surfaces a user-visible error on code-1008 (auth rejected) instead of reconnecting forever. - ErrorBoundary wraps the page — a bad card render shows a 'rendering error, reload view' card instead of crashing the tab. Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21) - empty-board shape now asserts all 6 columns including 'triage' - create_triage_lands_in_triage_column - triage_task_not_promoted_to_ready (dispatcher bypasses triage) - patch_status_triage_works (both into triage and out of it) - board_progress_rollup (0/2 -> 1/2 -> childless cards = None) - board_auto_initializes_missing_db - ws_events_rejects_when_token_required (three sub-assertions: missing → 1008, wrong → 1008, correct → handshake accepted) All 82 kanban tests pass under scripts/run_tests.sh. Docs - kanban.md 'What the plugin gives you' fully rewritten to match shipped reality (triage, progress pill, assignee lanes, destructive-confirm, Escape-close, debounce). - New 'Security model' subsection documents the explicit-plugin- route-bypass, the WS token requirement, and the --host 0.0.0.0 warning; also notes that kanban.db is profile-agnostic on purpose (the coordination primitive) so cross-profile visibility is expected. - CLI command reference shows --triage. - Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
for expected in ("triage", "todo", "ready", "running", "blocked", "done"):
feat(kanban): dashboard plugin — Linear/Fusion-style board UI Ships plugins/kanban/dashboard/ as a bundled dashboard plugin. No core changes — uses the standard dashboard plugin contract (manifest.json + dist/index.js + plugin_api.py) documented in 'Extending the Dashboard'. What the tab gives you: - One column per kanban status (todo / ready / running / blocked / done; archived behind a toggle), column counts, coloured status dots. - Cards with id, title, priority badge, tenant tag, assignee, comment/link counts, 'created N ago'. - HTML5 drag-drop between columns — status change routes through the same kanban_db code the CLI /kanban verbs use, so the three surfaces (CLI, gateway, dashboard) can never drift. - Inline create per-column (title, assignee, priority). - Side drawer on card click: description, status action row (→ ready / → running / block / unblock / complete / archive), dependency links, comment thread with Enter-to-submit, last 20 events. - Toolbar: search, tenant filter, assignee filter, show-archived, nudge-dispatcher (skip the 60s wait), refresh. - Live updates via WebSocket tailing task_events — the board reflects CLI or gateway actions in real time. REST surface under /api/plugins/kanban/: GET /board, GET /tasks/:id, POST /tasks, PATCH /tasks/:id, POST /tasks/:id/comments, POST /links, DELETE /links, POST /dispatch, WS /events. Every handler is a thin wrapper around kanban_db — no new business logic. Visually theme-aware: the plugin CSS reads only --color-*, --radius, --font-mono etc. so it reskins with whichever dashboard theme is active. Tests (tests/plugins/test_kanban_dashboard_plugin.py, 16 tests): - empty board shape - create + appears in ready column with tenant/assignee rollups - tenant filter - detail includes parents/children/events - 404 on unknown task - PATCH status: complete / block / unblock / ready drag-drop / running - PATCH reassign, priority, edit, invalid-status rejection - POST comment (plus empty-body rejection) - POST link + DELETE link + cycle rejection - POST dispatch (dry run) All 76 kanban tests pass under scripts/run_tests.sh. Docs: website/docs/user-guide/features/kanban.md gains a full 'Dashboard (GUI)' section covering install, architecture, REST surface, live-updates mechanism, extending, and scope boundary.
2026-04-26 12:08:47 -07:00
assert expected in names, f"missing column {expected}: {names}"
assert all(len(c["tasks"]) == 0 for c in data["columns"])
assert data["tenants"] == []
assert data["assignees"] == []
assert data["latest_event_id"] == 0
# ---------------------------------------------------------------------------
# POST /tasks then GET /board sees it
# ---------------------------------------------------------------------------
def test_create_task_appears_on_board(client):
r = client.post(
"/api/plugins/kanban/tasks",
json={
"title": "Research LLM caching",
"assignee": "researcher",
"priority": 3,
"tenant": "acme",
},
)
assert r.status_code == 200, r.text
task = r.json()["task"]
assert task["title"] == "Research LLM caching"
assert task["assignee"] == "researcher"
assert task["status"] == "ready" # no parents -> immediately ready
assert task["priority"] == 3
assert task["tenant"] == "acme"
task_id = task["id"]
# Board now lists it under 'ready'.
r = client.get("/api/plugins/kanban/board")
assert r.status_code == 200
data = r.json()
ready = next(c for c in data["columns"] if c["name"] == "ready")
assert len(ready["tasks"]) == 1
assert ready["tasks"][0]["id"] == task_id
assert "acme" in data["tenants"]
assert "researcher" in data["assignees"]
def test_tenant_filter(client):
client.post("/api/plugins/kanban/tasks", json={"title": "A", "tenant": "t1"})
client.post("/api/plugins/kanban/tasks", json={"title": "B", "tenant": "t2"})
r = client.get("/api/plugins/kanban/board?tenant=t1")
counts = {c["name"]: len(c["tasks"]) for c in r.json()["columns"]}
total = sum(counts.values())
assert total == 1
r = client.get("/api/plugins/kanban/board?tenant=t2")
total = sum(len(c["tasks"]) for c in r.json()["columns"])
assert total == 1
# ---------------------------------------------------------------------------
# GET /tasks/:id returns body + comments + events + links
# ---------------------------------------------------------------------------
def test_task_detail_includes_links_and_events(client):
parent = client.post(
"/api/plugins/kanban/tasks", json={"title": "parent"},
).json()["task"]
child = client.post(
"/api/plugins/kanban/tasks",
json={"title": "child", "parents": [parent["id"]]},
).json()["task"]
assert child["status"] == "todo" # parent not done yet
# Detail for the child shows the parent link.
r = client.get(f"/api/plugins/kanban/tasks/{child['id']}")
assert r.status_code == 200
data = r.json()
assert data["task"]["id"] == child["id"]
assert parent["id"] in data["links"]["parents"]
# Detail for the parent shows the child.
r = client.get(f"/api/plugins/kanban/tasks/{parent['id']}")
assert child["id"] in r.json()["links"]["children"]
# Events exist from creation.
assert len(data["events"]) >= 1
def test_task_detail_404_on_unknown(client):
r = client.get("/api/plugins/kanban/tasks/does-not-exist")
assert r.status_code == 404
# ---------------------------------------------------------------------------
# PATCH /tasks/:id — status transitions
# ---------------------------------------------------------------------------
def test_patch_status_complete(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "done", "result": "shipped"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "done"
# Board reflects the move.
done = next(
c for c in client.get("/api/plugins/kanban/board").json()["columns"]
if c["name"] == "done"
)
assert any(x["id"] == t["id"] for x in done["tasks"])
def test_patch_block_then_unblock(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "blocked", "block_reason": "need input"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "blocked"
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "ready"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "ready"
def test_patch_drag_drop_move_todo_to_ready(client):
"""Direct status write: the drag-drop path for statuses without a
dedicated verb (e.g. manually promoting todo -> ready)."""
parent = client.post("/api/plugins/kanban/tasks", json={"title": "p"}).json()["task"]
child = client.post(
"/api/plugins/kanban/tasks",
json={"title": "c", "parents": [parent["id"]]},
).json()["task"]
assert child["status"] == "todo"
r = client.patch(
f"/api/plugins/kanban/tasks/{child['id']}",
json={"status": "ready"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "ready"
def test_patch_reassign(client):
t = client.post(
"/api/plugins/kanban/tasks",
json={"title": "x", "assignee": "a"},
).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"assignee": "b"},
)
assert r.status_code == 200
assert r.json()["task"]["assignee"] == "b"
def test_patch_priority_and_edit(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"priority": 5, "title": "renamed"},
)
assert r.status_code == 200
data = r.json()["task"]
assert data["priority"] == 5
assert data["title"] == "renamed"
def test_patch_invalid_status(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}",
json={"status": "banana"},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Comments + Links
# ---------------------------------------------------------------------------
def test_add_comment(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.post(
f"/api/plugins/kanban/tasks/{t['id']}/comments",
json={"body": "how's progress?", "author": "teknium"},
)
assert r.status_code == 200
r = client.get(f"/api/plugins/kanban/tasks/{t['id']}")
comments = r.json()["comments"]
assert len(comments) == 1
assert comments[0]["body"] == "how's progress?"
assert comments[0]["author"] == "teknium"
def test_add_comment_empty_rejected(client):
t = client.post("/api/plugins/kanban/tasks", json={"title": "x"}).json()["task"]
r = client.post(
f"/api/plugins/kanban/tasks/{t['id']}/comments",
json={"body": " "},
)
assert r.status_code == 400
def test_add_link_and_delete_link(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
r = client.post(
"/api/plugins/kanban/links",
json={"parent_id": a["id"], "child_id": b["id"]},
)
assert r.status_code == 200
r = client.get(f"/api/plugins/kanban/tasks/{b['id']}")
assert a["id"] in r.json()["links"]["parents"]
r = client.delete(
"/api/plugins/kanban/links",
params={"parent_id": a["id"], "child_id": b["id"]},
)
assert r.status_code == 200
assert r.json()["ok"] is True
def test_add_link_cycle_rejected(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
client.post(
"/api/plugins/kanban/links",
json={"parent_id": a["id"], "child_id": b["id"]},
)
r = client.post(
"/api/plugins/kanban/links",
json={"parent_id": b["id"], "child_id": a["id"]},
)
assert r.status_code == 400
# ---------------------------------------------------------------------------
# Dispatch nudge
# ---------------------------------------------------------------------------
def test_dispatch_dry_run(client):
client.post(
"/api/plugins/kanban/tasks",
json={"title": "work", "assignee": "researcher"},
)
r = client.post("/api/plugins/kanban/dispatch?dry_run=true&max=4")
assert r.status_code == 200
body = r.json()
# DispatchResult is serialized as a dataclass dict.
assert isinstance(body, dict)
feat(kanban): Triage column, progress rollup, WS auth, lanes, polish Follows up on the initial dashboard plugin with the items called out during self-review — ships the GUI-reality claims the PR body made, closes the WebSocket auth gap, and lands the 'Triage' status the design spec's Fusion-style screenshot leads with. Kernel changes - kanban_db.VALID_STATUSES gains 'triage'. status is TEXT without a CHECK constraint so no schema migration is needed. - create_task(triage=True) forces the initial status to 'triage' regardless of parents, and parent ids are still validated so the eventual link rows don't dangle. recompute_ready() only promotes 'todo' -> 'ready', so triage tasks are naturally isolated from the dispatcher pipeline. - hermes kanban create gains --triage. Patterns table (docs) gains P9 'Triage specifier'. Plugin backend (plugins/kanban/dashboard/plugin_api.py) - GET /board now auto-init's kanban.db on first read (idempotent). A fresh install shows an empty board instead of 'failed to load'. - GET /board returns a new 'progress' field per task — {done, total} of child-task completion, or None if the task has no children. - BOARD_COLUMNS prepends 'triage'. - POST /tasks accepts {triage: bool}; PATCH /tasks/:id accepts {status: 'triage'}. - WebSocket /events now requires ?token=<session_token> as a query param — browsers can't set Authorization on a WS upgrade, so this matches the pattern the in-browser PTY bridge uses. Constant-time compare against hermes_cli.web_server._SESSION_TOKEN. In bare-test contexts (no dashboard module) the check no-ops so the tail loop stays testable. Security boundary documented in the module header and in website/docs/user-guide/features/kanban.md. Plugin UI (plugins/kanban/dashboard/dist/index.js + style.css) - Adds the Triage column (lilac dot) with helper text 'Raw ideas — a specifier will flesh out the spec'. Inline-create from the Triage column parks new tasks in triage. - Status action row in the drawer gains '→ triage'. - Progress pill (N/M) on cards that have children. Full-complete state tints the pill green. - 'Lanes by profile' toolbar toggle — sub-groups the Running column by assignee so you see at a glance which specialist is busy on what. - Destructive status moves (done / archived / blocked) via drag-drop OR via the drawer action row now prompt for confirmation. - Escape closes the drawer. - Live-update reloads are debounced (250ms) so a burst of task_events triggers one refetch, not N. - WebSocket includes ?token= built from window.__HERMES_SESSION_TOKEN__. - WebSocket reconnect uses exponential backoff capped at 30s, not a fixed 1.5s spin loop, and surfaces a user-visible error on code-1008 (auth rejected) instead of reconnecting forever. - ErrorBoundary wraps the page — a bad card render shows a 'rendering error, reload view' card instead of crashing the tab. Tests (tests/plugins/test_kanban_dashboard_plugin.py, +5 tests = 21) - empty-board shape now asserts all 6 columns including 'triage' - create_triage_lands_in_triage_column - triage_task_not_promoted_to_ready (dispatcher bypasses triage) - patch_status_triage_works (both into triage and out of it) - board_progress_rollup (0/2 -> 1/2 -> childless cards = None) - board_auto_initializes_missing_db - ws_events_rejects_when_token_required (three sub-assertions: missing → 1008, wrong → 1008, correct → handshake accepted) All 82 kanban tests pass under scripts/run_tests.sh. Docs - kanban.md 'What the plugin gives you' fully rewritten to match shipped reality (triage, progress pill, assignee lanes, destructive-confirm, Escape-close, debounce). - New 'Security model' subsection documents the explicit-plugin- route-bypass, the WS token requirement, and the --host 0.0.0.0 warning; also notes that kanban.db is profile-agnostic on purpose (the coordination primitive) so cross-profile visibility is expected. - CLI command reference shows --triage. - Collaboration patterns table adds P9 'Triage specifier'.
2026-04-26 12:26:43 -07:00
# ---------------------------------------------------------------------------
# Triage column (new v1 status)
# ---------------------------------------------------------------------------
def test_create_triage_lands_in_triage_column(client):
r = client.post(
"/api/plugins/kanban/tasks",
json={"title": "rough idea, spec me", "triage": True},
)
assert r.status_code == 200
task = r.json()["task"]
assert task["status"] == "triage"
r = client.get("/api/plugins/kanban/board")
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
assert len(triage["tasks"]) == 1
assert triage["tasks"][0]["title"] == "rough idea, spec me"
def test_triage_task_not_promoted_to_ready(client):
"""Triage tasks must stay in triage even when they have no parents."""
client.post(
"/api/plugins/kanban/tasks",
json={"title": "must stay put", "triage": True},
)
# Run the dispatcher — it should NOT promote the triage task.
client.post("/api/plugins/kanban/dispatch?dry_run=false&max=4")
r = client.get("/api/plugins/kanban/board")
triage = next(c for c in r.json()["columns"] if c["name"] == "triage")
ready = next(c for c in r.json()["columns"] if c["name"] == "ready")
assert len(triage["tasks"]) == 1
assert len(ready["tasks"]) == 0
def test_patch_status_triage_works(client):
"""A user (or specifier) can push a task back into triage, and out of it."""
t = client.post(
"/api/plugins/kanban/tasks", json={"title": "x"},
).json()["task"]
# Normal creation is 'ready'; push to triage.
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "triage"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "triage"
# Now promote to todo.
r = client.patch(
f"/api/plugins/kanban/tasks/{t['id']}", json={"status": "todo"},
)
assert r.status_code == 200
assert r.json()["task"]["status"] == "todo"
# ---------------------------------------------------------------------------
# Progress rollup (done children / total children)
# ---------------------------------------------------------------------------
def test_board_progress_rollup(client):
parent = client.post(
"/api/plugins/kanban/tasks", json={"title": "parent"},
).json()["task"]
child_a = client.post(
"/api/plugins/kanban/tasks",
json={"title": "a", "parents": [parent["id"]]},
).json()["task"]
child_b = client.post(
"/api/plugins/kanban/tasks",
json={"title": "b", "parents": [parent["id"]]},
).json()["task"]
# Children start as "todo" because the parent isn't done yet; promote
# them to "ready" so complete_task will accept the transition.
for cid in (child_a["id"], child_b["id"]):
r = client.patch(
f"/api/plugins/kanban/tasks/{cid}", json={"status": "ready"},
)
assert r.status_code == 200
# 0/2 done.
r = client.get("/api/plugins/kanban/board")
parent_row = next(
t for col in r.json()["columns"] for t in col["tasks"]
if t["id"] == parent["id"]
)
assert parent_row["progress"] == {"done": 0, "total": 2}
# Complete one child. 1/2.
r = client.patch(
f"/api/plugins/kanban/tasks/{child_a['id']}",
json={"status": "done"},
)
assert r.status_code == 200
r = client.get("/api/plugins/kanban/board")
parent_row = next(
t for col in r.json()["columns"] for t in col["tasks"]
if t["id"] == parent["id"]
)
assert parent_row["progress"] == {"done": 1, "total": 2}
# Childless tasks report progress=None, not {0/0}.
assert next(
t for col in r.json()["columns"] for t in col["tasks"]
if t["id"] == child_b["id"]
)["progress"] is None
# ---------------------------------------------------------------------------
# Auto-init on first board read
# ---------------------------------------------------------------------------
def test_board_auto_initializes_missing_db(tmp_path, monkeypatch):
"""If kanban.db doesn't exist yet, GET /board must create it, not 500."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
# Deliberately DO NOT call kb.init_db().
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
c = TestClient(app)
r = c.get("/api/plugins/kanban/board")
assert r.status_code == 200
assert (home / "kanban.db").exists(), "init_db wasn't invoked by /board"
# ---------------------------------------------------------------------------
# WebSocket auth (query-param token)
# ---------------------------------------------------------------------------
def test_ws_events_rejects_when_token_required(tmp_path, monkeypatch):
"""When _SESSION_TOKEN is set (normal dashboard context), a missing or
wrong ?token= query param must be rejected with policy-violation."""
home = tmp_path / ".hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
kb.init_db()
# Stub web_server so _check_ws_token has a token to compare against.
import types
stub = types.SimpleNamespace(_SESSION_TOKEN="secret-xyz")
monkeypatch.setitem(sys.modules, "hermes_cli.web_server", stub)
app = FastAPI()
app.include_router(_load_plugin_router(), prefix="/api/plugins/kanban")
c = TestClient(app)
# No token → policy violation close.
from starlette.websockets import WebSocketDisconnect
with pytest.raises(WebSocketDisconnect) as exc:
with c.websocket_connect("/api/plugins/kanban/events"):
pass
assert exc.value.code == 1008
# Wrong token → policy violation close.
with pytest.raises(WebSocketDisconnect) as exc:
with c.websocket_connect("/api/plugins/kanban/events?token=nope"):
pass
assert exc.value.code == 1008
# Correct token → accepted (connect then close cleanly from our side).
with c.websocket_connect(
"/api/plugins/kanban/events?token=secret-xyz"
) as ws:
assert ws is not None # handshake succeeded
feat(kanban): bulk ops, drawer edit, dep editor, markdown, touch, config The dashboard plugin gets the last layer of features that turn it from a 'usable read surface with drag-drop' into a 'full kanban UI' — no more 'drop to CLI to do X' moments from inside the tab. Plugin backend - POST /tasks/bulk — apply the same patch (status / archive / assignee / priority) to every id in the request body. Each id runs independently: one bad id reports {ok: false, error: ...} without aborting siblings. Status transitions that aren't legal for the current state are surfaced per-id ('transition to done refused'). Used by the multi-select bulk action bar. - GET /config — returns the dashboard.kanban section of config.yaml (default_tenant, lane_by_profile, include_archived_by_default, render_markdown) with sensible defaults when the section is absent. Loaded once by the SPA to preselect filters and toggle markdown rendering. - _conn() helper — every handler now goes through it, calling kanban_db.init_db() (idempotent) before every connection. Fresh installs work whether the first hit is GET /board, POST /tasks, or any other endpoint — no more 'no such table: tasks' when the CLI or a script hits the plugin before the dashboard has ever loaded. Plugin UI (plugin bundle, +~12 KB) - Multi-select: per-card checkbox; shift/ctrl-click also toggles without opening the drawer. A BulkActionBar appears above the columns with batch → ready / complete / archive / reassign (profile dropdown + unassign option). Destructive batches confirm first. Partial failures from the backend are surfaced inline. - Drawer inline editing: - Click the title → TitleEditor swaps in an input, Enter saves, Escape cancels. - Click the Assignee meta row → AssigneeEditor input (empty string unassigns). - Click the Priority meta row → PriorityEditor numeric input. - New 'edit' button on Description → full-width textarea; Save / Cancel switch back to rendered view. - Dependency editor: chip list of parents + children with per-chip × button (calls DELETE /links). Add-parent / add-child dropdowns filter out self + already-linked tasks so you cannot re-add a duplicate edge or a self-loop. Cycle rejections from the server surface cleanly via the existing error banner. - Parent selection in InlineCreate: new dropdown listing every task on the board ('{id} — {title}') — picking one sends parents=[id] with the create payload, so the task lands in todo (or triage if created from the Triage column) with the dependency wired up. - Safe markdown rendering for description, comment bodies, and result. A small in-bundle renderer handles headings, bold, italic, inline code, fenced code, bullet lists, and http(s)/mailto links. Every substitution runs on HTML-escaped input (no raw HTML), links get target=_blank + rel=noopener,noreferrer. Disabled by config key dashboard.kanban.render_markdown=false (falls back to <pre>). - Touch drag-drop: attachTouchDrag() installs a pointerdown handler that spawns a drag proxy, tracks elementFromPoint under the finger, and dispatches a hermes-kanban:drop CustomEvent on the column when released. Desktop continues to use native HTML5 DnD. Columns listen for both. - ErrorBoundary already present from the prior commit catches any renderer throw; markdown escape + touch-proxy cleanup both have their own try/finally. Tests (tests/plugins/test_kanban_dashboard_plugin.py — 90/90 pass) - bulk_status_ready: 3 tasks blocked, batch → ready, all move - bulk_archive hides all ids from default board - bulk_reassign changes every assignee - bulk_unassign_via_empty_string sets assignee back to None - bulk_partial_failure_doesnt_abort_siblings: bogus id in middle, good siblings still get priority=7 - bulk_empty_ids_400 - config_returns_defaults_when_section_missing - config_reads_dashboard_kanban_section (writes config.yaml, verifies every key round-trips) Live smoke (real FastAPI app + isolated HERMES_HOME): - /config without section returns defaults - /config with dashboard.kanban section returns the configured values - POST /tasks as the first-ever request (no prior /board) succeeds — auto-init handles it - Link add + remove via POST /links + DELETE /links round-trip - Bulk priority bump on 2 ids, both get priority=5 - Bulk archive hides ids from default board - PATCH {title, body} updates the task, markdown source survives the round trip - POST /tasks {triage: true, parents: [id]} lands in triage, not todo - Bulk partial: 2 good + 1 bogus returns per-id outcome Docs (website/docs/user-guide/features/kanban.md) - 'What the plugin gives you' rewritten to reflect bulk, drawer edit, dep editor, parent-on-create, markdown, touch drag-drop. - New 'Dashboard config' subsection with a YAML example for dashboard.kanban.*. - REST table gains /tasks/bulk and /config rows.
2026-04-26 12:36:23 -07:00
# ---------------------------------------------------------------------------
# Bulk actions
# ---------------------------------------------------------------------------
def test_bulk_status_ready(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
# Parent-less tasks land in "ready" already; push them to blocked first.
for tid in (a["id"], b["id"], c2["id"]):
client.patch(f"/api/plugins/kanban/tasks/{tid}",
json={"status": "blocked", "block_reason": "wait"})
r = client.post("/api/plugins/kanban/tasks/bulk",
json={"ids": [a["id"], b["id"], c2["id"]], "status": "ready"})
assert r.status_code == 200
results = r.json()["results"]
assert all(r["ok"] for r in results)
# All three are now ready.
board = client.get("/api/plugins/kanban/board").json()
ready = next(col for col in board["columns"] if col["name"] == "ready")
ids = {t["id"] for t in ready["tasks"]}
assert {a["id"], b["id"], c2["id"]}.issubset(ids)
def test_bulk_archive(client):
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks", json={"title": "b"}).json()["task"]
r = client.post("/api/plugins/kanban/tasks/bulk",
json={"ids": [a["id"], b["id"]], "archive": True})
assert r.status_code == 200
assert all(r["ok"] for r in r.json()["results"])
# Default board (archived hidden) — both gone.
board = client.get("/api/plugins/kanban/board").json()
ids = {t["id"] for col in board["columns"] for t in col["tasks"]}
assert a["id"] not in ids
assert b["id"] not in ids
def test_bulk_reassign(client):
a = client.post("/api/plugins/kanban/tasks",
json={"title": "a", "assignee": "old"}).json()["task"]
b = client.post("/api/plugins/kanban/tasks",
json={"title": "b", "assignee": "old"}).json()["task"]
r = client.post("/api/plugins/kanban/tasks/bulk",
json={"ids": [a["id"], b["id"]], "assignee": "new"})
assert r.status_code == 200
for tid in (a["id"], b["id"]):
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
assert t["assignee"] == "new"
def test_bulk_unassign_via_empty_string(client):
a = client.post("/api/plugins/kanban/tasks",
json={"title": "a", "assignee": "x"}).json()["task"]
r = client.post("/api/plugins/kanban/tasks/bulk",
json={"ids": [a["id"]], "assignee": ""})
assert r.status_code == 200
t = client.get(f"/api/plugins/kanban/tasks/{a['id']}").json()["task"]
assert t["assignee"] is None
def test_bulk_partial_failure_doesnt_abort_siblings(client):
"""One bad id in the middle of a batch must not prevent others from
applying."""
a = client.post("/api/plugins/kanban/tasks", json={"title": "a"}).json()["task"]
c2 = client.post("/api/plugins/kanban/tasks", json={"title": "c"}).json()["task"]
r = client.post("/api/plugins/kanban/tasks/bulk",
json={"ids": [a["id"], "bogus-id", c2["id"]], "priority": 7})
assert r.status_code == 200
results = r.json()["results"]
assert len(results) == 3
ok_ids = {r["id"] for r in results if r["ok"]}
assert a["id"] in ok_ids
assert c2["id"] in ok_ids
assert any(not r["ok"] and r["id"] == "bogus-id" for r in results)
# Good siblings actually got the priority bump.
for tid in (a["id"], c2["id"]):
t = client.get(f"/api/plugins/kanban/tasks/{tid}").json()["task"]
assert t["priority"] == 7
def test_bulk_empty_ids_400(client):
r = client.post("/api/plugins/kanban/tasks/bulk", json={"ids": []})
assert r.status_code == 400
# ---------------------------------------------------------------------------
# /config endpoint
# ---------------------------------------------------------------------------
def test_config_returns_defaults_when_section_missing(client):
r = client.get("/api/plugins/kanban/config")
assert r.status_code == 200
data = r.json()
# Defaults when dashboard.kanban is missing.
assert data["default_tenant"] == ""
assert data["lane_by_profile"] is True
assert data["include_archived_by_default"] is False
assert data["render_markdown"] is True
def test_config_reads_dashboard_kanban_section(tmp_path, monkeypatch, client):
home = Path(os.environ["HERMES_HOME"])
(home / "config.yaml").write_text(
"dashboard:\n"
" kanban:\n"
" default_tenant: acme\n"
" lane_by_profile: false\n"
" include_archived_by_default: true\n"
" render_markdown: false\n"
)
r = client.get("/api/plugins/kanban/config")
assert r.status_code == 200
data = r.json()
assert data["default_tenant"] == "acme"
assert data["lane_by_profile"] is False
assert data["include_archived_by_default"] is True
assert data["render_markdown"] is False