feat(#4470): expose skills, max_runtime_seconds, and parents in Kanban create modal

This commit is contained in:
Rod Boev committed 2026-06-29 15:27:16 -04:00
1 parent 99f487697d
commit aaabd32c92
4 files changed
+358 -9

No files matched your search

+84
View File
@@ -953,6 +953,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -2592,6 +2598,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -4222,6 +4234,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -5556,6 +5574,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -7123,6 +7147,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -8668,6 +8698,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -10268,6 +10304,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -12123,6 +12165,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -13641,6 +13689,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -15169,6 +15223,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -16817,6 +16877,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Répertoire',
kanban_workspace_path_required: 'Le chemin de l\'espace de travail est requis pour les espaces de travail non-brouillon.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Ajouter une dépendance',
kanban_remove_dependency: 'Supprimer la dépendance',
kanban_dependency_placeholder: 'ID de la tâche à lier',
@@ -18408,6 +18474,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Directory',
kanban_workspace_path_required: 'Workspace path is required for non-scratch workspaces.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Add dependency',
kanban_remove_dependency: 'Remove dependency',
kanban_dependency_placeholder: 'Task ID to link',
@@ -20053,6 +20125,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Katalog',
kanban_workspace_path_required: 'Ścieżka do obszaru roboczego jest wymagana dla obszarów innych niż tymczasowe (scratch).',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Dodaj zależność',
kanban_remove_dependency: 'Usuń zależność',
kanban_dependency_placeholder: 'Identyfikator zadania do powiązania',
@@ -21528,6 +21606,12 @@ const LOCALES = {
kanban_workspace_worktree: 'Worktree',
kanban_workspace_dir: 'Thư mục',
kanban_workspace_path_required: 'Workspace không phải tạm thời cần có đường dẫn.',
kanban_skills: 'Skills',
kanban_skills_placeholder: 'Comma-separated skill names, e.g. python,git',
kanban_max_runtime_seconds: 'Max runtime (seconds)',
kanban_max_runtime_hint: 'Leave empty for unlimited.',
kanban_parents_modal: 'Parent dependencies',
kanban_parents_placeholder: 'Parent task ID',
kanban_add_dependency: 'Thêm phụ thuộc',
kanban_remove_dependency: 'Xóa phụ thuộc',
kanban_dependency_placeholder: 'ID tác vụ cần liên kết',
+17 -1
View File
@@ -1725,6 +1725,7 @@
<div class="kanban-modal-row">
<label for="kanbanTaskModalPriority" data-i18n="kanban_priority">Priority</label>
<input type="number" id="kanbanTaskModalPriority" value="0" min="-100" max="100" step="1">
<div class="kanban-modal-hint" id="kanbanTaskModalPriorityHint" data-i18n="kanban_priority_hint">Higher numbers run first. Default 0.</div>
</div>
</div>
<div class="kanban-modal-row">
@@ -1749,7 +1750,22 @@
</div>
<div class="kanban-modal-row" id="kanbanTaskModalWorkspacePathRow" style="display:none">
<label for="kanbanTaskModalWorkspacePath" data-i18n="kanban_workspace_path">Workspace path</label>
<input type="text" id="kanbanTaskModalWorkspacePath" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_workspace_path_placeholder" placeholder="Path to workspace">
<input type="text" id="kanbanTaskModalWorkspacePath" list="kanbanTaskModalWorkspacePathList" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_workspace_path_placeholder" placeholder="Path to workspace">
<datalist id="kanbanTaskModalWorkspacePathList"></datalist>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalSkills" data-i18n="kanban_skills">Skills</label>
<input type="text" id="kanbanTaskModalSkills" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_skills_placeholder" placeholder="Comma-separated skill names, e.g. python,git">
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalMaxRuntimeSeconds" data-i18n="kanban_max_runtime_seconds">Max runtime (seconds)</label>
<input type="number" id="kanbanTaskModalMaxRuntimeSeconds" min="1" step="1" autocomplete="off">
<div class="kanban-modal-hint" data-i18n="kanban_max_runtime_hint">Leave empty for unlimited.</div>
</div>
<div class="kanban-modal-row">
<label for="kanbanTaskModalParents" data-i18n="kanban_parents_modal">Parent dependencies</label>
<input type="text" id="kanbanTaskModalParents" list="kanbanTaskModalParentsList" maxlength="255" autocomplete="off" data-i18n-placeholder="kanban_parents_placeholder" placeholder="Parent task ID">
<datalist id="kanbanTaskModalParentsList"></datalist>
</div>
<div class="kanban-modal-error" id="kanbanTaskModalError" aria-live="polite"></div>
<div class="kanban-modal-actions">
+51 -8
View File
@@ -2688,6 +2688,8 @@ function openKanbanCreate(){
}
});
_kanbanPopulateTenantDatalist();
_kanbanPopulateWorkspacePathDatalist();
_kanbanPopulateParentsDatalist();
modal.hidden = false;
if (_kanbanTaskModalFocusCleanup) {
_kanbanTaskModalFocusCleanup();
@@ -2778,6 +2780,9 @@ function _kanbanResetTaskModalFields(values){
// .value before the options exist would silently fail.
set('kanbanTaskModalTenant', v.tenant || '');
set('kanbanTaskModalPriority', v.priority != null ? v.priority : 0);
set('kanbanTaskModalSkills', Array.isArray(v.skills) ? v.skills.join(', ') : (v.skills || ''));
set('kanbanTaskModalMaxRuntimeSeconds', v.max_runtime_seconds != null ? v.max_runtime_seconds : '');
set('kanbanTaskModalParents', '');
const errEl = document.getElementById('kanbanTaskModalError');
if (errEl) { errEl.textContent = ''; delete errEl.dataset.warningShown; }
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
@@ -2787,20 +2792,25 @@ function _kanbanResetTaskModalFields(values){
function _kanbanSetTaskModalLabels(mode){
const titleH = document.getElementById('kanbanTaskModalTitle');
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath');
if (mode === 'edit') {
if (titleH) titleH.textContent = t('kanban_edit_task') || 'Edit task';
if (submitBtn) submitBtn.textContent = t('save') || 'Save';
// Disable workspace fields during edit since they are not handled by the backend
if (workspaceKindEl) workspaceKindEl.disabled = true;
if (workspacePathEl) workspacePathEl.disabled = true;
} else {
if (titleH) titleH.textContent = t('kanban_new_task') || 'New task';
if (submitBtn) submitBtn.textContent = t('create') || 'Create';
// Enable workspace fields during create
if (workspaceKindEl) workspaceKindEl.disabled = false;
if (workspacePathEl) workspacePathEl.disabled = false;
}
// Workspace and new backend fields are create-only; backend patch doesn't handle them.
const createOnlyIds = [
'kanbanTaskModalWorkspaceKind',
'kanbanTaskModalWorkspacePath',
'kanbanTaskModalSkills',
'kanbanTaskModalMaxRuntimeSeconds',
'kanbanTaskModalParents',
];
const disabled = mode === 'edit';
for (const id of createOnlyIds) {
const el = document.getElementById(id);
if (el) el.disabled = disabled;
}
}
@@ -2823,6 +2833,27 @@ function _kanbanPopulateTenantDatalist(){
if (tList) tList.innerHTML = tenants.map(v => `<option value="${esc(v)}"></option>`).join('');
}
function _kanbanPopulateWorkspacePathDatalist(){
const cols = (_kanbanBoard && _kanbanBoard.columns) || [];
const seen = new Set();
const opts = [];
for (const col of cols) {
for (const task of (col.tasks || [])) {
const path = task && task.workspace_path;
if (!path || seen.has(path)) continue;
seen.add(path);
opts.push(`<option value="${esc(path)}"></option>`);
}
}
const list = document.getElementById('kanbanTaskModalWorkspacePathList');
if (list) list.innerHTML = opts.join('');
}
function _kanbanPopulateParentsDatalist(){
const list = document.getElementById('kanbanTaskModalParentsList');
if (list) list.innerHTML = _kanbanLinkableTaskOptions(null);
}
function _trapModalFocus(modalEl){
if (!modalEl) return () => {};
const selector = 'a[href], button, textarea, input, select, summary, [tabindex]:not([tabindex="-1"])';
@@ -2911,6 +2942,9 @@ async function submitKanbanTaskModal(){
const priorityEl = document.getElementById('kanbanTaskModalPriority');
const workspaceKindEl = document.getElementById('kanbanTaskModalWorkspaceKind');
const workspacePathEl = document.getElementById('kanbanTaskModalWorkspacePath');
const skillsEl = document.getElementById('kanbanTaskModalSkills');
const maxRuntimeEl = document.getElementById('kanbanTaskModalMaxRuntimeSeconds');
const parentsEl = document.getElementById('kanbanTaskModalParents');
const errEl = document.getElementById('kanbanTaskModalError');
const submitBtn = document.getElementById('kanbanTaskModalSubmit');
const title = titleEl ? titleEl.value.trim() : '';
@@ -2939,6 +2973,9 @@ async function submitKanbanTaskModal(){
const statusVal = statusEl ? statusEl.value : '';
const priorityRaw = priorityEl ? priorityEl.value : '';
const workspacePathVal = workspacePathEl ? workspacePathEl.value.trim() : '';
const skillsRaw = skillsEl ? skillsEl.value.trim() : '';
const maxRuntimeRaw = maxRuntimeEl ? maxRuntimeEl.value.trim() : '';
const parentsRaw = parentsEl ? parentsEl.value.trim() : '';
if (isEdit) {
payload.body = bodyVal;
payload.assignee = assigneeVal || null;
@@ -2965,6 +3002,12 @@ async function submitKanbanTaskModal(){
}
payload.workspace_kind = workspaceKind;
if (workspacePathVal) payload.workspace_path = workspacePathVal;
if (skillsRaw) {
payload.skills = skillsRaw.split(',').map(s => s.trim()).filter(Boolean);
}
const maxRuntimeN = maxRuntimeRaw !== '' ? parseInt(maxRuntimeRaw, 10) : NaN;
if (!Number.isNaN(maxRuntimeN) && maxRuntimeN > 0) payload.max_runtime_seconds = maxRuntimeN;
if (parentsRaw) payload.parents = [parentsRaw];
}
// Soft warning: a Ready task with the explicit "Unassigned" option will sit
// forever because the dispatcher skips unassigned rows (kanban_db.py:3567).
@@ -0,0 +1,206 @@
"""Source-inspection and behavioral tests for Kanban modal fields added in #4470."""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
INDEX_HTML = (ROOT / "static" / "index.html").read_text(encoding="utf-8")
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
I18N_JS = (ROOT / "static" / "i18n.js").read_text(encoding="utf-8")
KANBAN_BRIDGE = (ROOT / "api" / "kanban_bridge.py").read_text(encoding="utf-8")
NODE = shutil.which("node")
def test_modal_html_exposes_new_fields():
for field_id in (
"kanbanTaskModalSkills",
"kanbanTaskModalMaxRuntimeSeconds",
"kanbanTaskModalParents",
"kanbanTaskModalParentsList",
"kanbanTaskModalWorkspacePathList",
"kanbanTaskModalPriorityHint",
):
assert f'id="{field_id}"' in INDEX_HTML, f"Missing: {field_id}"
assert 'list="kanbanTaskModalParentsList"' in INDEX_HTML
assert 'list="kanbanTaskModalWorkspacePathList"' in INDEX_HTML
assert 'data-i18n="kanban_priority_hint"' in INDEX_HTML
def _submit_body():
m = re.search(
r"async function submitKanbanTaskModal\(\)\s*\{(.*?)\n\}",
PANELS_JS, re.DOTALL,
)
assert m, "submitKanbanTaskModal() not found"
return m.group(1)
def test_submit_reads_and_sends_skills_as_array():
body = _submit_body()
assert "kanbanTaskModalSkills" in body
assert "payload.skills" in body
assert ".split(',')" in body or ".split(', ')" in body
def test_submit_reads_and_sends_max_runtime_as_integer():
body = _submit_body()
assert "kanbanTaskModalMaxRuntimeSeconds" in body
assert "payload.max_runtime_seconds" in body
assert "parseInt" in body
def test_submit_reads_and_sends_parents_as_list():
body = _submit_body()
assert "kanbanTaskModalParents" in body
assert "payload.parents" in body
assert "[parentsRaw]" in body or "payload.parents = [" in body
def test_new_fields_only_in_create_branch():
body = _submit_body()
edit_match = re.search(r"if \(isEdit\) \{(.*?)\} else \{", body, re.DOTALL)
assert edit_match, "isEdit...else block not found"
edit_branch = edit_match.group(1)
assert "payload.skills" not in edit_branch
assert "payload.max_runtime_seconds" not in edit_branch
assert "payload.parents" not in edit_branch
def test_reset_handler_covers_new_fields():
m = re.search(
r"function _kanbanResetTaskModalFields\([^)]*\)\s*\{(.*?)\n\}",
PANELS_JS, re.DOTALL,
)
assert m, "_kanbanResetTaskModalFields() not found"
body = m.group(1)
assert "kanbanTaskModalSkills" in body
assert "kanbanTaskModalMaxRuntimeSeconds" in body
assert "kanbanTaskModalParents" in body
def test_labels_handler_disables_new_fields_in_edit():
m = re.search(
r"function _kanbanSetTaskModalLabels\([^)]*\)\s*\{(.*?)\n\}",
PANELS_JS, re.DOTALL,
)
assert m, "_kanbanSetTaskModalLabels() not found"
body = m.group(1)
assert "kanbanTaskModalSkills" in body
assert "kanbanTaskModalMaxRuntimeSeconds" in body
assert "kanbanTaskModalParents" in body
assert "disabled" in body
def test_datalist_helpers_exist_and_are_called():
fn_wp = "function _kanbanPopulateWorkspacePathDatalist"
fn_par = "function _kanbanPopulateParentsDatalist"
assert fn_wp in PANELS_JS
assert "kanbanTaskModalWorkspacePathList" in PANELS_JS
# Check that workspace_path appears inside the function definition body
fn_start = PANELS_JS.find(fn_wp)
assert "workspace_path" in PANELS_JS[fn_start:fn_start + 500], \
"workspace_path not found in _kanbanPopulateWorkspacePathDatalist body"
assert fn_par in PANELS_JS
assert "_kanbanLinkableTaskOptions(null)" in PANELS_JS
m = re.search(r"function openKanbanCreate\(\)\s*\{(.*?)\n\}", PANELS_JS, re.DOTALL)
assert m, "openKanbanCreate() not found"
body = m.group(1)
assert "_kanbanPopulateWorkspacePathDatalist()" in body
assert "_kanbanPopulateParentsDatalist()" in body
def test_i18n_new_keys_in_all_locales():
required = [
"kanban_skills",
"kanban_skills_placeholder",
"kanban_max_runtime_seconds",
"kanban_max_runtime_hint",
"kanban_parents_modal",
"kanban_parents_placeholder",
]
for key in required:
count = I18N_JS.count(f"{key}:")
assert count == 14, f"Key '{key}' appears {count} times, expected 14 (one per locale)"
def test_backend_already_accepts_new_fields():
assert "skills=body.get(" in KANBAN_BRIDGE
assert "max_runtime_seconds=body.get(" in KANBAN_BRIDGE
assert "parents=body.get(" in KANBAN_BRIDGE
@pytest.mark.skipif(shutil.which("node") is None, reason="node not available")
def test_submit_payload_behavioral():
"""Node.js execution: extract submitKanbanTaskModal and verify payload construction."""
# Find the full function body
fn_start = PANELS_JS.find("async function submitKanbanTaskModal(")
assert fn_start >= 0, "submitKanbanTaskModal not found"
brace_start = PANELS_JS.index("{", fn_start)
depth = 0
fn_end = brace_start
for i in range(brace_start, len(PANELS_JS)):
if PANELS_JS[i] == "{":
depth += 1
elif PANELS_JS[i] == "}":
depth -= 1
if depth == 0:
fn_end = i + 1
break
fn_body = PANELS_JS[fn_start:fn_end]
harness = (
"// Stub minimal DOM\n"
"const noop = () => {};\n"
"const elements = {\n"
" kanbanTaskModalTitleInput: { value: 'Test Task', focus: noop },\n"
" kanbanTaskModalBody: { value: '' },\n"
" kanbanTaskModalStatus: { value: 'triage' },\n"
" kanbanTaskModalAssignee: { value: 'agent1', focus: noop },\n"
" kanbanTaskModalTenant: { value: '' },\n"
" kanbanTaskModalPriority: { value: '0' },\n"
" kanbanTaskModalWorkspaceKind: { value: 'scratch', disabled: false },\n"
" kanbanTaskModalWorkspacePath: { value: '', disabled: false },\n"
" kanbanTaskModalSkills: { value: 'python, git', disabled: false },\n"
" kanbanTaskModalMaxRuntimeSeconds: { value: '120', disabled: false },\n"
" kanbanTaskModalParents: { value: 't_abc', disabled: false },\n"
" kanbanTaskModalError: { textContent: '', dataset: {} },\n"
" kanbanTaskModalSubmit: { disabled: false },\n"
"};\n"
"global.document = { getElementById: (id) => elements[id] || null };\n"
"function t(k) { return k; }\n"
"let capturedPayload = null;\n"
"async function api(url, opts) {\n"
" capturedPayload = opts ? JSON.parse(opts.body) : null;\n"
" return { task: { id: 't_new' } };\n"
"}\n"
"async function loadKanban() {}\n"
"async function loadKanbanTask() {}\n"
"function _kanbanBoardQuery() { return ''; }\n"
"function closeKanbanTaskModal() {}\n"
"let _kanbanTaskModalMode = 'create';\n"
"let _kanbanTaskModalEditingId = null;\n"
"let _kanbanTaskModalInitialDisplayedStatus = null;\n"
+ fn_body + "\n"
"submitKanbanTaskModal().then(() => {\n"
" console.log(JSON.stringify(capturedPayload));\n"
"}).catch(e => { process.stderr.write(String(e)); process.exit(1); });\n"
)
result = subprocess.run(
[NODE, "-e", harness],
capture_output=True, text=True, timeout=15,
)
assert result.returncode == 0, f"Node error: {result.stderr}"
output = result.stdout.strip()
assert output, f"No payload captured. stderr: {result.stderr}"
payload = json.loads(output)
assert payload.get("skills") == ["python", "git"], f"skills wrong: {payload.get('skills')}"
assert payload.get("max_runtime_seconds") == 120, f"max_runtime wrong: {payload.get('max_runtime_seconds')}"
assert payload.get("parents") == ["t_abc"], f"parents wrong: {payload.get('parents')}"