feat: harden Kanban review feedback
- add canonical PATCH and DELETE routing for Kanban writes - fix task detail log rendering and add close/back affordance - improve timestamps, event summaries, stats HUD, and mobile layout - cover route and detail behavior with targeted tests
This commit is contained in:
1 parent
d7671f8366
commit
711e33e7db
8 files changed
+223
-15
No files matched your search
@@ -594,3 +594,34 @@ def handle_kanban_post(handler, parsed, body) -> bool:
|
||||
except RuntimeError as exc:
|
||||
return bad(handler, str(exc), status=409)
|
||||
return False
|
||||
|
||||
|
||||
def handle_kanban_patch(handler, parsed, body) -> bool:
|
||||
path = parsed.path
|
||||
try:
|
||||
if path.startswith(_TASK_PREFIX):
|
||||
task_id = unquote(path[len(_TASK_PREFIX):]).strip("/")
|
||||
if not task_id or "/" in task_id:
|
||||
return False
|
||||
return j(handler, _patch_task_payload(task_id, body)) or True
|
||||
except LookupError as exc:
|
||||
return bad(handler, str(exc), status=404)
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc))
|
||||
except RuntimeError as exc:
|
||||
return bad(handler, str(exc), status=409)
|
||||
return False
|
||||
|
||||
|
||||
def handle_kanban_delete(handler, parsed, body) -> bool:
|
||||
path = parsed.path
|
||||
try:
|
||||
if path == "/api/kanban/links":
|
||||
return j(handler, _link_tasks_payload(body, unlink=True)) or True
|
||||
except LookupError as exc:
|
||||
return bad(handler, str(exc), status=404)
|
||||
except ValueError as exc:
|
||||
return bad(handler, str(exc))
|
||||
except RuntimeError as exc:
|
||||
return bad(handler, str(exc), status=409)
|
||||
return False
|
||||
@@ -3778,6 +3778,30 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
return False # 404
|
||||
|
||||
|
||||
def handle_patch(handler, parsed) -> bool:
|
||||
"""Handle all PATCH routes. Returns True if handled, False for 404."""
|
||||
if not _check_csrf(handler):
|
||||
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||||
body = read_body(handler)
|
||||
if parsed.path.startswith("/api/kanban/"):
|
||||
from api.kanban_bridge import handle_kanban_patch
|
||||
|
||||
return handle_kanban_patch(handler, parsed, body)
|
||||
return False
|
||||
|
||||
|
||||
def handle_delete(handler, parsed) -> bool:
|
||||
"""Handle all DELETE routes. Returns True if handled, False for 404."""
|
||||
if not _check_csrf(handler):
|
||||
return j(handler, {"error": "Cross-origin request rejected"}, status=403)
|
||||
body = read_body(handler)
|
||||
if parsed.path.startswith("/api/kanban/"):
|
||||
from api.kanban_bridge import handle_kanban_delete
|
||||
|
||||
return handle_kanban_delete(handler, parsed, body)
|
||||
return False
|
||||
|
||||
# ── GET route helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
# MIME types for static file serving. Hoisted to module scope to avoid
|
||||
|
||||
@@ -22,7 +22,7 @@ from api.auth import check_auth
|
||||
from api.config import HOST, PORT, STATE_DIR, SESSION_DIR, DEFAULT_WORKSPACE
|
||||
from api.helpers import j, get_profile_cookie
|
||||
from api.profiles import set_request_profile, clear_request_profile
|
||||
from api.routes import handle_get, handle_post
|
||||
from api.routes import handle_delete, handle_get, handle_patch, handle_post
|
||||
from api.startup import auto_install_agent_deps, fix_credential_permissions
|
||||
from api.updates import WEBUI_VERSION
|
||||
|
||||
@@ -137,7 +137,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
def _handle_write(self, route_func) -> None:
|
||||
self._req_t0 = time.time()
|
||||
# Per-request profile context from cookie (issue #798)
|
||||
cookie_profile = get_profile_cookie(self)
|
||||
@@ -146,7 +146,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
if not check_auth(self, parsed): return
|
||||
result = handle_post(self, parsed)
|
||||
result = route_func(self, parsed)
|
||||
if result is False:
|
||||
return j(self, {'error': 'not found'}, status=404)
|
||||
except Exception as e:
|
||||
@@ -155,6 +155,15 @@ class Handler(BaseHTTPRequestHandler):
|
||||
finally:
|
||||
clear_request_profile()
|
||||
|
||||
def do_POST(self) -> None:
|
||||
self._handle_write(handle_post)
|
||||
|
||||
def do_PATCH(self) -> None:
|
||||
self._handle_write(handle_patch)
|
||||
|
||||
def do_DELETE(self) -> None:
|
||||
self._handle_write(handle_delete)
|
||||
|
||||
|
||||
def _raise_fd_soft_limit(target: int = 4096) -> dict:
|
||||
"""Best-effort raise of RLIMIT_NOFILE for persistent WebUI hosts.
|
||||
|
||||
@@ -488,6 +488,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -1435,6 +1436,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -2224,6 +2226,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -3107,6 +3110,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -3978,6 +3982,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -4870,6 +4875,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -6763,6 +6769,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
@@ -7616,6 +7623,7 @@ const LOCALES = {
|
||||
kanban_worker_log: 'Worker log',
|
||||
kanban_block: 'Block',
|
||||
kanban_unblock: 'Unblock',
|
||||
kanban_back_to_board: 'Back to board',
|
||||
kanban_lanes_by_profile: 'Lanes by profile',
|
||||
kanban_card_start: 'start',
|
||||
kanban_card_complete: 'complete',
|
||||
|
||||
+55
-10
@@ -1116,7 +1116,10 @@ async function loadKanbanStats(){
|
||||
if (!el) return;
|
||||
const byStatus = (stats && stats.by_status) || {};
|
||||
const total = Object.values(byStatus).reduce((a, b) => a + Number(b || 0), 0);
|
||||
el.textContent = `${t('kanban_stats')}: ${total}`;
|
||||
const cells = Object.entries(byStatus).sort(([a], [b]) => a.localeCompare(b)).map(([status, count]) =>
|
||||
`<span class="kanban-stat-cell"><strong>${esc(String(count))}</strong> ${esc(_kanbanColumnLabel(status))}</span>`
|
||||
).join('');
|
||||
el.innerHTML = `<div class="kanban-stats-grid"><span class="kanban-stat-cell total"><strong>${esc(String(total))}</strong> ${esc(t('kanban_stats'))}</span>${cells}</div>`;
|
||||
} catch(e) { /* stats are best-effort */ }
|
||||
}
|
||||
|
||||
@@ -1179,6 +1182,45 @@ async function unblockKanbanTask(taskId){
|
||||
} catch(e) { showToast(t('kanban_unavailable') + ': ' + (e.message || e), 'error'); }
|
||||
}
|
||||
|
||||
function closeKanbanTaskDetail(){
|
||||
_kanbanCurrentTaskId = null;
|
||||
const preview = $('kanbanTaskPreview');
|
||||
if (preview) {
|
||||
preview.style.display = 'none';
|
||||
preview.innerHTML = '';
|
||||
}
|
||||
const board = $('kanbanBoard');
|
||||
if (board) board.querySelectorAll('.kanban-card').forEach(card => card.classList.remove('selected'));
|
||||
}
|
||||
|
||||
function _kanbanFormatTimestamp(value){
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
let date = null;
|
||||
if (typeof value === 'number') date = new Date(value > 100000000000 ? value : value * 1000);
|
||||
else if (/^\d+(?:\.\d+)?$/.test(String(value).trim())) {
|
||||
const n = Number(value);
|
||||
date = new Date(n > 100000000000 ? n : n * 1000);
|
||||
} else {
|
||||
date = new Date(value);
|
||||
}
|
||||
if (!date || Number.isNaN(date.getTime())) return String(value);
|
||||
try { return date.toLocaleString(); } catch(e) { return date.toISOString(); }
|
||||
}
|
||||
|
||||
function _kanbanEventSummary(event){
|
||||
const kind = event.kind || event.type || 'event';
|
||||
const payload = event.payload || event.data || {};
|
||||
if (payload && typeof payload === 'object') {
|
||||
const parts = [];
|
||||
if (payload.status) parts.push(String(payload.status));
|
||||
if (payload.reason) parts.push(String(payload.reason));
|
||||
if (payload.summary) parts.push(String(payload.summary));
|
||||
if (payload.fields && Array.isArray(payload.fields)) parts.push(payload.fields.join(', '));
|
||||
if (parts.length) return `${kind}: ${parts.join(' · ')}`;
|
||||
}
|
||||
return String(kind);
|
||||
}
|
||||
|
||||
function _kanbanFormatDetailValue(value){
|
||||
if (value === undefined || value === null || value === '') return '';
|
||||
if (typeof value === 'object') {
|
||||
@@ -1198,7 +1240,7 @@ function _kanbanDetailSection(cls, title, inner, emptyKey){
|
||||
function _kanbanCommentHtml(comment){
|
||||
const body = comment.body || comment.text || comment.content || '';
|
||||
const by = comment.author || comment.created_by || comment.actor || '';
|
||||
const at = comment.created_at || comment.ts || '';
|
||||
const at = _kanbanFormatTimestamp(comment.created_at || comment.ts || '');
|
||||
return `<div class="kanban-detail-row">
|
||||
<div class="kanban-detail-row-main">${esc(body)}</div>
|
||||
<div class="kanban-detail-row-meta">${esc([by, at].filter(Boolean).join(' · '))}</div>
|
||||
@@ -1206,11 +1248,10 @@ function _kanbanCommentHtml(comment){
|
||||
}
|
||||
|
||||
function _kanbanEventHtml(event){
|
||||
const kind = event.kind || event.type || 'event';
|
||||
const at = event.created_at || event.ts || '';
|
||||
const at = _kanbanFormatTimestamp(event.created_at || event.ts || '');
|
||||
const payload = _kanbanFormatDetailValue(event.payload || event.data || '');
|
||||
return `<div class="kanban-detail-row">
|
||||
<div class="kanban-detail-row-main">${esc(kind)}</div>
|
||||
<div class="kanban-detail-row-main">${esc(_kanbanEventSummary(event))}</div>
|
||||
${payload ? `<pre class="kanban-detail-pre">${esc(payload)}</pre>` : ''}
|
||||
<div class="kanban-detail-row-meta">${esc(at)}</div>
|
||||
</div>`;
|
||||
@@ -1219,8 +1260,8 @@ function _kanbanEventHtml(event){
|
||||
function _kanbanRunHtml(run){
|
||||
const status = run.status || run.state || run.result || '';
|
||||
const label = run.run_id || run.id || run.worker || t('kanban_task');
|
||||
const started = run.started_at || run.created_at || '';
|
||||
const finished = run.finished_at || run.completed_at || '';
|
||||
const started = _kanbanFormatTimestamp(run.started_at || run.created_at || '');
|
||||
const finished = _kanbanFormatTimestamp(run.finished_at || run.completed_at || '');
|
||||
const detail = run.error || run.summary || run.log_tail || '';
|
||||
return `<div class="kanban-detail-row">
|
||||
<div class="kanban-detail-row-main">${esc(label)}${status ? ` · ${esc(status)}` : ''}</div>
|
||||
@@ -1258,8 +1299,8 @@ async function createKanbanTask(){
|
||||
async function updateKanbanTask(taskId, patch){
|
||||
if (!taskId || !patch) return;
|
||||
try {
|
||||
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId) + '/patch', {
|
||||
method: 'POST',
|
||||
const updated = await api('/api/kanban/tasks/' + encodeURIComponent(taskId), {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
await loadKanban(true);
|
||||
@@ -1283,6 +1324,7 @@ async function addKanbanComment(taskId){
|
||||
|
||||
function _kanbanRenderTaskDetail(data){
|
||||
const task = data.task || {};
|
||||
const log = data.log || {};
|
||||
const title = _kanbanTaskTitle(task);
|
||||
const body = _kanbanTaskBody(task) || t('kanban_no_description');
|
||||
const meta = _kanbanTaskMeta(task);
|
||||
@@ -1293,7 +1335,10 @@ function _kanbanRenderTaskDetail(data){
|
||||
const statusButtons = ['triage', 'todo', 'ready', 'running', 'blocked', 'done', 'archived'].map(status =>
|
||||
`<button class="btn secondary" onclick="updateKanbanTask('${esc(task.id)}',{status:'${status}'})">${esc(_kanbanColumnLabel(status))}</button>`
|
||||
).join('') + `<button class="btn secondary" onclick="blockKanbanTask('${esc(task.id)}')">${esc(t('kanban_block'))}</button><button class="btn secondary" onclick="unblockKanbanTask('${esc(task.id)}')">${esc(t('kanban_unblock'))}</button>`;
|
||||
return `<div class="kanban-task-preview-title">${esc(title)}</div>
|
||||
return `<div class="kanban-task-preview-header">
|
||||
<button class="btn secondary kanban-back-btn" onclick="closeKanbanTaskDetail()">${esc(t('kanban_back_to_board'))}</button>
|
||||
<div class="kanban-task-preview-title">${esc(title)}</div>
|
||||
</div>
|
||||
<div class="kanban-task-preview-body">${esc(body)}</div>
|
||||
${meta.length ? `<div class="kanban-meta">${esc(meta.join(' · '))}</div>` : ''}
|
||||
<div class="kanban-status-actions">${statusButtons}</div>
|
||||
|
||||
+14
-1
@@ -3145,7 +3145,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-new-task-row{display:flex;gap:6px;align-items:center;}
|
||||
.kanban-new-task-row input{flex:1;min-width:0;background:var(--input-bg);border:1px solid var(--border);border-radius:8px;color:var(--text);padding:7px 8px;font:inherit;font-size:12px;}
|
||||
.kanban-task-preview{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel);}
|
||||
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:6px;}
|
||||
.kanban-task-preview-header{display:flex;align-items:center;gap:10px;margin-bottom:6px;}
|
||||
.kanban-back-btn{flex-shrink:0;font-size:11px;padding:4px 8px;}
|
||||
.kanban-task-preview-title{font-size:14px;font-weight:650;color:var(--text);margin-bottom:0;}
|
||||
.kanban-task-preview-body{font-size:12px;color:var(--muted);line-height:1.45;white-space:pre-wrap;margin-bottom:6px;}
|
||||
.kanban-status-actions{display:flex;flex-wrap:wrap;gap:6px;margin:10px 0 4px;}
|
||||
.kanban-status-actions .btn{font-size:11px;padding:4px 8px;}
|
||||
@@ -3165,6 +3167,9 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-detail-links-grid code{display:inline-block;margin:4px 4px 0 0;padding:2px 5px;border-radius:5px;background:var(--input-bg);border:1px solid var(--border);color:var(--text);}
|
||||
|
||||
.kanban-stats{font-size:12px;color:var(--muted);padding:2px 0}
|
||||
.kanban-stats-grid{display:flex;gap:6px;align-items:center;flex-wrap:wrap;}
|
||||
.kanban-stat-cell{display:inline-flex;gap:4px;align-items:center;border:1px solid var(--border);border-radius:999px;background:var(--input-bg);padding:2px 7px;}
|
||||
.kanban-stat-cell.total{color:var(--text);}
|
||||
.kanban-bulk-bar{display:flex;gap:6px;align-items:center;flex-wrap:wrap}
|
||||
.kanban-bulk-bar select{flex:1;min-width:96px;background:var(--input-bg);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:4px 6px;font-size:12px}
|
||||
|
||||
@@ -3187,3 +3192,11 @@ main.main.showing-insights > #mainInsights{display:flex;overflow-y:auto;}
|
||||
.kanban-card-stale-red{border-color:rgba(255,95,95,.65)}
|
||||
.kanban-column.drop-target{outline:2px solid var(--accent);outline-offset:-2px}
|
||||
.hermes-kanban-md p{margin:0 0 4px}.hermes-kanban-md code{font-family:var(--mono);font-size:.95em}
|
||||
|
||||
@media (max-width: 640px){
|
||||
.kanban-board{scroll-snap-type:x mandatory;}
|
||||
.kanban-column{min-width:82vw;scroll-snap-align:start;}
|
||||
.kanban-task-preview-header{align-items:flex-start;flex-direction:column;}
|
||||
.kanban-comment-form{flex-direction:column;align-items:stretch;}
|
||||
.kanban-stats-grid{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px;}
|
||||
}
|
||||
@@ -411,3 +411,16 @@ def test_kanban_only_mine_bulk_dispatch_and_block_unblock(monkeypatch):
|
||||
assert unblocked["task"]["status"] == "ready"
|
||||
assert dispatch["dry_run"] is True
|
||||
assert dispatch["max_spawn"] == 2
|
||||
|
||||
|
||||
|
||||
def test_routes_dispatches_canonical_kanban_patch_and_delete_verbs():
|
||||
src = open("api/routes.py", encoding="utf-8").read()
|
||||
server = open("server.py", encoding="utf-8").read()
|
||||
assert "def do_PATCH" in server
|
||||
assert "def do_DELETE" in server
|
||||
assert "self._handle_write(handle_patch)" in server
|
||||
assert "self._handle_write(handle_delete)" in server
|
||||
assert 'parsed.path.startswith("/api/kanban/")' in src
|
||||
assert "handle_kanban_patch(handler, parsed, body)" in src
|
||||
assert "handle_kanban_delete(handler, parsed, body)" in src
|
||||
@@ -70,7 +70,8 @@ def test_kanban_write_mvp_has_native_controls_and_api_calls():
|
||||
assert "async function addKanbanComment" in PANELS
|
||||
assert "api('/api/kanban/tasks'," in PANELS
|
||||
assert "method: 'POST'" in PANELS
|
||||
assert "'/api/kanban/tasks/' + encodeURIComponent(taskId) + '/patch'" 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
|
||||
@@ -216,3 +217,67 @@ def test_kanban_ui_parity_polish_css_and_i18n_exist():
|
||||
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
|
||||
Reference in new issue
Block a user