diff --git a/static/i18n.js b/static/i18n.js index 53b15afd..c53074c0 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -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', diff --git a/static/index.html b/static/index.html index 6eb732d4..662439da 100644 --- a/static/index.html +++ b/static/index.html @@ -1725,6 +1725,7 @@
+
Higher numbers run first. Default 0.
@@ -1749,7 +1750,22 @@
+
+ + +
+
+ + +
Leave empty for unlimited.
+
+
+ + +
diff --git a/static/panels.js b/static/panels.js index 240d6654..59294151 100644 --- a/static/panels.js +++ b/static/panels.js @@ -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 => ``).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(``); + } + } + 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). diff --git a/tests/test_issue4470_kanban_task_editor_fields.py b/tests/test_issue4470_kanban_task_editor_fields.py new file mode 100644 index 00000000..0b76c475 --- /dev/null +++ b/tests/test_issue4470_kanban_task_editor_fields.py @@ -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')}"