summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
Diffstat (limited to 'web')
-rw-r--r--web/app.js103
-rw-r--r--web/index.html10
-rw-r--r--web/style.css166
-rw-r--r--web/test/active-tasks-tab.test.mjs68
-rw-r--r--web/test/delete-button.test.mjs60
-rw-r--r--web/test/filter-tabs.test.mjs90
-rw-r--r--web/test/running-view.test.mjs295
-rw-r--r--web/test/sort-tasks.test.mjs88
-rw-r--r--web/test/task-actions.test.mjs29
9 files changed, 846 insertions, 63 deletions
diff --git a/web/app.js b/web/app.js
index ce1394d..97721d3 100644
--- a/web/app.js
+++ b/web/app.js
@@ -1,5 +1,5 @@
-const BASE_PATH = document.querySelector('meta[name="base-path"]')?.content ?? '';
-const API_BASE = window.location.origin + BASE_PATH;
+const BASE_PATH = (typeof document !== 'undefined') ? document.querySelector('meta[name="base-path"]')?.content ?? '' : '';
+const API_BASE = (typeof window !== 'undefined') ? window.location.origin + BASE_PATH : '';
// ── Fetch ─────────────────────────────────────────────────────────────────────
@@ -160,17 +160,56 @@ function createTaskCard(task) {
return card;
}
+// ── Sort ──────────────────────────────────────────────────────────────────────
+
+function sortTasksByDate(tasks) {
+ return [...tasks].sort((a, b) => {
+ if (!a.created_at && !b.created_at) return 0;
+ if (!a.created_at) return 1;
+ if (!b.created_at) return -1;
+ return new Date(a.created_at) - new Date(b.created_at);
+ });
+}
+
// ── Filter ────────────────────────────────────────────────────────────────────
const HIDE_STATES = new Set(['COMPLETED', 'FAILED']);
+const ACTIVE_STATES = new Set(['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']);
+const DONE_STATES = new Set(['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']);
-let showHiddenFold = false;
+// filterActiveTasks uses its own set (excludes PENDING — tasks "in-flight" only)
+const _PANEL_ACTIVE_STATES = new Set(['RUNNING', 'READY', 'QUEUED', 'BLOCKED']);
-function filterTasks(tasks, hideCompletedFailed = false) {
+export function filterTasks(tasks, hideCompletedFailed = false) {
if (!hideCompletedFailed) return tasks;
return tasks.filter(t => !HIDE_STATES.has(t.state));
}
+export function filterActiveTasks(tasks) {
+ return tasks.filter(t => _PANEL_ACTIVE_STATES.has(t.state));
+}
+
+export function filterTasksByTab(tasks, tab) {
+ if (tab === 'active') return tasks.filter(t => ACTIVE_STATES.has(t.state));
+ if (tab === 'done') return tasks.filter(t => DONE_STATES.has(t.state));
+ return tasks;
+}
+
+export function getTaskFilterTab() {
+ return localStorage.getItem('taskFilterTab') ?? 'active';
+}
+
+export function setTaskFilterTab(tab) {
+ localStorage.setItem('taskFilterTab', tab);
+}
+
+export function updateFilterTabs() {
+ const current = getTaskFilterTab();
+ document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
+ el.classList.toggle('active', el.dataset.filter === current);
+ });
+}
+
function getHideCompletedFailed() {
const stored = localStorage.getItem('hideCompletedFailed');
return stored === null ? true : stored === 'true';
@@ -196,36 +235,30 @@ function renderTaskList(tasks) {
return;
}
- const hide = getHideCompletedFailed();
- const visible = filterTasks(tasks, hide);
- const hiddenCount = tasks.length - visible.length;
+ const visible = sortTasksByDate(filterTasksByTab(tasks, getTaskFilterTab()));
// Replace contents with task cards
container.innerHTML = '';
for (const task of visible) {
container.appendChild(createTaskCard(task));
}
+}
- if (hiddenCount > 0) {
- const info = document.createElement('button');
- info.className = 'hidden-tasks-info';
- const arrow = showHiddenFold ? '▼' : '▶';
- info.textContent = `${arrow} ${hiddenCount} hidden task${hiddenCount === 1 ? '' : 's'}`;
- info.addEventListener('click', () => {
- showHiddenFold = !showHiddenFold;
- renderTaskList(tasks);
- });
- container.appendChild(info);
-
- if (showHiddenFold) {
- const fold = document.createElement('div');
- fold.className = 'hidden-tasks-fold';
- const hiddenTasks = tasks.filter(t => HIDE_STATES.has(t.state));
- for (const task of hiddenTasks) {
- fold.appendChild(createTaskCard(task));
- }
- container.appendChild(fold);
- }
+function renderActiveTaskList(tasks) {
+ const container = document.querySelector('.active-task-list');
+ if (!container) return;
+ if (!tasks || tasks.length === 0) {
+ container.innerHTML = '<div id="loading">No active tasks.</div>';
+ return;
+ }
+ const active = sortTasksByDate(filterActiveTasks(tasks));
+ container.innerHTML = '';
+ if (active.length === 0) {
+ container.innerHTML = '<div id="loading">No active tasks.</div>';
+ return;
+ }
+ for (const task of active) {
+ container.appendChild(createTaskCard(task));
}
}
@@ -762,6 +795,7 @@ async function poll() {
try {
const tasks = await fetchTasks();
renderTaskList(tasks);
+ renderActiveTaskList(tasks);
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -1539,12 +1573,15 @@ function switchTab(name) {
// ── Boot ──────────────────────────────────────────────────────────────────────
-document.addEventListener('DOMContentLoaded', () => {
- updateToggleButton();
- document.getElementById('btn-toggle-completed').addEventListener('click', async () => {
- setHideCompletedFailed(!getHideCompletedFailed());
- updateToggleButton();
- await poll();
+if (typeof document !== 'undefined') document.addEventListener('DOMContentLoaded', () => {
+ updateFilterTabs();
+
+ document.querySelectorAll(".filter-tab[data-filter]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ setTaskFilterTab(btn.dataset.filter);
+ updateFilterTabs();
+ poll();
+ });
});
document.getElementById('btn-start-next').addEventListener('click', function() {
diff --git a/web/index.html b/web/index.html
index 99fc190..e43823f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -16,11 +16,14 @@
<nav class="tab-bar">
<button class="tab active" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="templates">Templates</button>
+ <button class="tab" data-tab="active">Active</button>
</nav>
<main id="app">
<div data-panel="tasks">
<div class="task-list-toolbar">
- <button id="btn-toggle-completed" class="btn-secondary btn-sm"></button>
+ <button class="filter-tab active" data-filter="active">Active</button>
+ <button class="filter-tab" data-filter="done">Done</button>
+ <button class="filter-tab" data-filter="all">All</button>
<button id="btn-start-next" class="btn-secondary btn-sm">Start Next</button>
</div>
<div class="task-list">
@@ -34,6 +37,9 @@
</div>
<div class="template-list"></div>
</div>
+ <div data-panel="active" hidden>
+ <div class="active-task-list"></div>
+ </div>
</main>
<dialog id="task-modal">
@@ -124,6 +130,6 @@
</div>
</dialog>
- <script src="app.js" defer></script>
+ <script type="module" src="app.js"></script>
</body>
</html>
diff --git a/web/style.css b/web/style.css
index 91466ee..106ae04 100644
--- a/web/style.css
+++ b/web/style.css
@@ -115,10 +115,39 @@ main {
padding: 0.5rem 0;
margin-bottom: 0.75rem;
border-bottom: 1px solid var(--border);
+ gap: 0;
+}
+
+.filter-tab {
+ font-size: 0.78rem;
+ font-weight: 600;
+ padding: 0.3em 0.75em;
+ border: none;
+ border-bottom: 2px solid transparent;
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+ transition: color 0.15s, border-color 0.15s;
+ margin-bottom: -1px;
+}
+
+.filter-tab:hover {
+ color: var(--text);
+}
+
+.filter-tab.active {
+ color: var(--accent);
+ border-bottom-color: var(--accent);
+}
+
+/* Spacer to push remaining toolbar items to the right */
+.task-list-toolbar .filter-tab:last-of-type {
+ margin-right: auto;
}
/* Task list */
-.task-list {
+.task-list,
+.active-task-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
@@ -343,29 +372,6 @@ main {
color: var(--state-failed);
}
-.hidden-tasks-info {
- font-size: 0.78rem;
- color: var(--text-muted);
- text-align: center;
- padding: 0.5rem 0;
- cursor: pointer;
- background: transparent;
- border: none;
- width: 100%;
-}
-
-.hidden-tasks-info:hover {
- color: var(--text);
-}
-
-.hidden-tasks-fold {
- display: flex;
- flex-direction: column;
- gap: 0.75rem;
- opacity: 0.6;
- margin-top: 0.5rem;
-}
-
/* Primary button */
.btn-primary {
font-size: 0.85rem;
@@ -1009,6 +1015,11 @@ dialog label select:focus {
margin-top: 8px;
}
+.log-error {
+ color: #f87171;
+ font-style: italic;
+}
+
/* ── Validate section ────────────────────────────────────────────────────── */
.validate-section {
@@ -1045,3 +1056,110 @@ dialog label select:focus {
.validate-suggestion {
color: #94a3b8;
}
+
+/* ── Task delete button ──────────────────────────────────────────────────── */
+
+.task-card {
+ position: relative;
+}
+
+.btn-delete-task {
+ position: absolute;
+ top: 6px;
+ right: 6px;
+ background: transparent;
+ border: none;
+ color: #64748b;
+ font-size: 0.75rem;
+ line-height: 1;
+ padding: 2px 5px;
+ border-radius: 3px;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
+}
+
+.task-card:hover .btn-delete-task {
+ opacity: 1;
+}
+
+.btn-delete-task:hover {
+ background: var(--state-failed, #ef4444);
+ color: #fff;
+}
+
+/* ── Inline task editor ─────────────────────────────────────────────────────── */
+
+.task-card--editable:hover {
+ background: rgba(56, 189, 248, 0.04);
+}
+
+.task-inline-edit {
+ margin-top: 0.75rem;
+ padding-top: 0.75rem;
+ border-top: 1px solid var(--border);
+}
+
+.task-inline-edit label {
+ display: block;
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-bottom: 0.625rem;
+}
+
+.task-inline-edit label input,
+.task-inline-edit label textarea,
+.task-inline-edit label select {
+ display: block;
+ width: 100%;
+ margin-top: 4px;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ color: var(--text);
+ padding: 0.4em 0.6em;
+ font-size: 0.9rem;
+ font-family: inherit;
+}
+
+.task-inline-edit label input:focus,
+.task-inline-edit label textarea:focus,
+.task-inline-edit label select:focus {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+.inline-edit-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ margin-top: 0.75rem;
+}
+
+.inline-edit-actions button[type="button"]:not(.btn-primary) {
+ font-size: 0.85rem;
+ padding: 0.4em 1em;
+ border-radius: 0.375rem;
+ border: 1px solid var(--border);
+ background: transparent;
+ color: var(--text-muted);
+ cursor: pointer;
+}
+
+.inline-edit-actions button[type="button"]:not(.btn-primary):hover {
+ background: var(--border);
+ color: var(--text);
+}
+
+.inline-edit-error {
+ color: var(--state-failed);
+ font-size: 0.82rem;
+ margin-top: 0.5rem;
+}
+
+.inline-edit-success {
+ color: var(--state-completed);
+ font-size: 0.82rem;
+ margin-top: 0.25rem;
+ text-align: right;
+}
diff --git a/web/test/active-tasks-tab.test.mjs b/web/test/active-tasks-tab.test.mjs
new file mode 100644
index 0000000..7b68c6f
--- /dev/null
+++ b/web/test/active-tasks-tab.test.mjs
@@ -0,0 +1,68 @@
+// active-tasks-tab.test.mjs — TDD contract tests for filterActiveTasks
+//
+// filterActiveTasks is imported from app.js. Tests are RED until the function
+// is exported from app.js.
+//
+// Run with: node --test web/test/active-tasks-tab.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterActiveTasks } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('filterActiveTasks', () => {
+ it('returns only RUNNING tasks when given mixed states', () => {
+ const tasks = [makeTask('RUNNING'), makeTask('COMPLETED'), makeTask('PENDING')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'RUNNING');
+ });
+
+ it('returns only READY tasks', () => {
+ const tasks = [makeTask('READY'), makeTask('FAILED'), makeTask('TIMED_OUT')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'READY');
+ });
+
+ it('returns only QUEUED tasks', () => {
+ const tasks = [makeTask('QUEUED'), makeTask('CANCELLED'), makeTask('PENDING')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'QUEUED');
+ });
+
+ it('returns only BLOCKED tasks', () => {
+ const tasks = [makeTask('BLOCKED'), makeTask('BUDGET_EXCEEDED'), makeTask('COMPLETED')];
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].state, 'BLOCKED');
+ });
+
+ it('returns all four active states together, excludes PENDING/COMPLETED/FAILED/TIMED_OUT/CANCELLED/BUDGET_EXCEEDED', () => {
+ const allStates = [
+ 'RUNNING', 'READY', 'QUEUED', 'BLOCKED',
+ 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+ ];
+ const tasks = allStates.map(makeTask);
+ const result = filterActiveTasks(tasks);
+ assert.equal(result.length, 4, 'exactly 4 active-state tasks should be returned');
+ for (const state of ['RUNNING', 'READY', 'QUEUED', 'BLOCKED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ for (const state of ['PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterActiveTasks([]), []);
+ });
+});
diff --git a/web/test/delete-button.test.mjs b/web/test/delete-button.test.mjs
new file mode 100644
index 0000000..b82b487
--- /dev/null
+++ b/web/test/delete-button.test.mjs
@@ -0,0 +1,60 @@
+// delete-button.test.mjs — visibility logic for the Delete button on task cards
+//
+// Run with: node --test web/test/delete-button.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Logic under test ──────────────────────────────────────────────────────────
+// A delete button should be shown for any task that is not actively executing.
+// RUNNING and QUEUED tasks cannot be deleted via the API (409), so we hide the button.
+
+const NON_DELETABLE_STATES = new Set(['RUNNING', 'QUEUED']);
+
+function showDeleteButton(state) {
+ return !NON_DELETABLE_STATES.has(state);
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('delete button visibility', () => {
+ it('shows for PENDING', () => {
+ assert.equal(showDeleteButton('PENDING'), true);
+ });
+
+ it('shows for COMPLETED', () => {
+ assert.equal(showDeleteButton('COMPLETED'), true);
+ });
+
+ it('shows for FAILED', () => {
+ assert.equal(showDeleteButton('FAILED'), true);
+ });
+
+ it('shows for CANCELLED', () => {
+ assert.equal(showDeleteButton('CANCELLED'), true);
+ });
+
+ it('shows for TIMED_OUT', () => {
+ assert.equal(showDeleteButton('TIMED_OUT'), true);
+ });
+
+ it('shows for BUDGET_EXCEEDED', () => {
+ assert.equal(showDeleteButton('BUDGET_EXCEEDED'), true);
+ });
+
+ it('shows for READY', () => {
+ assert.equal(showDeleteButton('READY'), true);
+ });
+
+ it('shows for BLOCKED', () => {
+ assert.equal(showDeleteButton('BLOCKED'), true);
+ });
+
+ it('hides for RUNNING', () => {
+ assert.equal(showDeleteButton('RUNNING'), false);
+ });
+
+ it('hides for QUEUED', () => {
+ assert.equal(showDeleteButton('QUEUED'), false);
+ });
+});
diff --git a/web/test/filter-tabs.test.mjs b/web/test/filter-tabs.test.mjs
new file mode 100644
index 0000000..44cfaf6
--- /dev/null
+++ b/web/test/filter-tabs.test.mjs
@@ -0,0 +1,90 @@
+// filter-tabs.test.mjs — TDD contract tests for filterTasksByTab
+//
+// filterTasksByTab is defined inline here to establish expected behaviour.
+// Once filterTasksByTab is exported from web/app.js, remove the inline
+// definition and import it instead.
+//
+// Run with: node --test web/test/filter-tabs.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { filterTasksByTab } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+const ALL_STATES = [
+ 'PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED',
+ 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('filterTasksByTab — active tab', () => {
+ it('includes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'active');
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ });
+
+ it('excludes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'active');
+ for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'active'), []);
+ });
+});
+
+describe('filterTasksByTab — done tab', () => {
+ it('includes COMPLETED, FAILED, TIMED_OUT, CANCELLED, BUDGET_EXCEEDED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'done');
+ for (const state of ['COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED']) {
+ assert.ok(result.some(t => t.state === state), `${state} should be included`);
+ }
+ });
+
+ it('excludes PENDING, QUEUED, RUNNING, READY, BLOCKED', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'done');
+ for (const state of ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'BLOCKED']) {
+ assert.ok(!result.some(t => t.state === state), `${state} should be excluded`);
+ }
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'done'), []);
+ });
+});
+
+describe('filterTasksByTab — all tab', () => {
+ it('returns all tasks unchanged', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'all');
+ assert.equal(result.length, ALL_STATES.length);
+ assert.strictEqual(result, tasks, 'should return the same array reference');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(filterTasksByTab([], 'all'), []);
+ });
+});
+
+describe('filterTasksByTab — unknown tab', () => {
+ it('returns all tasks as defensive fallback', () => {
+ const tasks = ALL_STATES.map(makeTask);
+ const result = filterTasksByTab(tasks, 'unknown-tab');
+ assert.equal(result.length, ALL_STATES.length);
+ assert.strictEqual(result, tasks, 'should return the same array reference');
+ });
+});
diff --git a/web/test/running-view.test.mjs b/web/test/running-view.test.mjs
new file mode 100644
index 0000000..88419bc
--- /dev/null
+++ b/web/test/running-view.test.mjs
@@ -0,0 +1,295 @@
+// running-view.test.mjs — pure function tests for the Running tab
+//
+// Tests:
+// filterRunningTasks(tasks) — returns only tasks where state === RUNNING
+// formatElapsed(startISO) — returns elapsed string like "2m 30s", "1h 5m"
+// fetchRecentExecutions(basePath, fetchFn) — calls /api/executions?since=24h
+// formatDuration(startISO, endISO) — returns duration string for history table
+//
+// Run with: node --test web/test/running-view.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Inline implementations ─────────────────────────────────────────────────────
+
+function extractLogLines(lines, max = 500) {
+ if (lines.length <= max) return lines;
+ return lines.slice(lines.length - max);
+}
+
+function filterRunningTasks(tasks) {
+ return tasks.filter(t => t.state === 'RUNNING');
+}
+
+function formatElapsed(startISO) {
+ if (startISO == null) return '';
+ const elapsed = Math.floor((Date.now() - new Date(startISO).getTime()) / 1000);
+ if (elapsed < 0) return '0s';
+ const h = Math.floor(elapsed / 3600);
+ const m = Math.floor((elapsed % 3600) / 60);
+ const s = elapsed % 60;
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+async function fetchRecentExecutions(basePath, fetchFn) {
+ const res = await fetchFn(`${basePath}/api/executions?since=24h`);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json();
+}
+
+// formatDuration: returns human-readable duration between two ISO timestamps.
+// If endISO is null/undefined, uses now (for in-progress tasks).
+// If startISO is null/undefined, returns '--'.
+function formatDuration(startISO, endISO) {
+ if (startISO == null) return '--';
+ const start = new Date(startISO).getTime();
+ const end = endISO != null ? new Date(endISO).getTime() : Date.now();
+ const elapsed = Math.max(0, Math.floor((end - start) / 1000));
+ const h = Math.floor(elapsed / 3600);
+ const m = Math.floor((elapsed % 3600) / 60);
+ const s = elapsed % 60;
+ if (h > 0) return `${h}h ${m}m`;
+ if (m > 0) return `${m}m ${s}s`;
+ return `${s}s`;
+}
+
+// ── Tests: filterRunningTasks ─────────────────────────────────────────────────
+
+describe('filterRunningTasks', () => {
+ it('returns only RUNNING tasks from mixed list', () => {
+ const tasks = [
+ { id: '1', state: 'RUNNING' },
+ { id: '2', state: 'COMPLETED' },
+ { id: '3', state: 'RUNNING' },
+ { id: '4', state: 'QUEUED' },
+ ];
+ const result = filterRunningTasks(tasks);
+ assert.equal(result.length, 2);
+ assert.ok(result.every(t => t.state === 'RUNNING'));
+ });
+
+ it('returns empty array when no tasks are RUNNING', () => {
+ const tasks = [
+ { id: '1', state: 'COMPLETED' },
+ { id: '2', state: 'QUEUED' },
+ ];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+
+ it('handles empty task list', () => {
+ assert.deepEqual(filterRunningTasks([]), []);
+ });
+
+ it('does not include QUEUED tasks', () => {
+ const tasks = [{ id: '1', state: 'QUEUED' }];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+
+ it('does not include READY tasks', () => {
+ const tasks = [{ id: '1', state: 'READY' }];
+ assert.deepEqual(filterRunningTasks(tasks), []);
+ });
+});
+
+// ── Tests: formatElapsed ──────────────────────────────────────────────────────
+
+describe('formatElapsed', () => {
+ it('returns empty string for null', () => {
+ assert.equal(formatElapsed(null), '');
+ });
+
+ it('returns empty string for undefined', () => {
+ assert.equal(formatElapsed(undefined), '');
+ });
+
+ it('returns Xs format for elapsed under a minute', () => {
+ const start = new Date(Date.now() - 45 * 1000).toISOString();
+ assert.equal(formatElapsed(start), '45s');
+ });
+
+ it('returns Xm Ys format for 2 minutes 30 seconds ago', () => {
+ const start = new Date(Date.now() - (2 * 60 + 30) * 1000).toISOString();
+ assert.equal(formatElapsed(start), '2m 30s');
+ });
+
+ it('returns Xh Ym format for over an hour', () => {
+ const start = new Date(Date.now() - (1 * 3600 + 5 * 60) * 1000).toISOString();
+ assert.equal(formatElapsed(start), '1h 5m');
+ });
+
+ it('returns 0s for future timestamp', () => {
+ const start = new Date(Date.now() + 60 * 1000).toISOString();
+ assert.equal(formatElapsed(start), '0s');
+ });
+});
+
+// ── Tests: fetchRecentExecutions ──────────────────────────────────────────────
+
+describe('fetchRecentExecutions', () => {
+ it('calls /api/executions?since=24h with basePath prefix', async () => {
+ let calledUrl;
+ const mockFetch = async (url) => {
+ calledUrl = url;
+ return { ok: true, json: async () => [] };
+ };
+ await fetchRecentExecutions('/claudomator', mockFetch);
+ assert.equal(calledUrl, '/claudomator/api/executions?since=24h');
+ });
+
+ it('calls with empty basePath', async () => {
+ let calledUrl;
+ const mockFetch = async (url) => {
+ calledUrl = url;
+ return { ok: true, json: async () => [] };
+ };
+ await fetchRecentExecutions('', mockFetch);
+ assert.equal(calledUrl, '/api/executions?since=24h');
+ });
+
+ it('returns parsed JSON response', async () => {
+ const data = [{ id: 'exec-1', task_id: 't-1', status: 'COMPLETED' }];
+ const mockFetch = async () => ({ ok: true, json: async () => data });
+ const result = await fetchRecentExecutions('', mockFetch);
+ assert.deepEqual(result, data);
+ });
+
+ it('throws on non-OK HTTP status', async () => {
+ const mockFetch = async () => ({ ok: false, status: 500 });
+ await assert.rejects(
+ () => fetchRecentExecutions('', mockFetch),
+ /HTTP 500/,
+ );
+ });
+});
+
+// ── Tests: formatDuration ─────────────────────────────────────────────────────
+
+describe('formatDuration', () => {
+ it('returns -- for null startISO', () => {
+ assert.equal(formatDuration(null, null), '--');
+ });
+
+ it('returns -- for undefined startISO', () => {
+ assert.equal(formatDuration(undefined, null), '--');
+ });
+
+ it('returns Xs for duration under a minute', () => {
+ const start = new Date(Date.now() - 45 * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '45s');
+ });
+
+ it('returns Xm Ys for duration between 1 and 60 minutes', () => {
+ const start = new Date(Date.now() - (3 * 60 + 15) * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '3m 15s');
+ });
+
+ it('returns Xh Ym for duration over an hour', () => {
+ const start = new Date(Date.now() - (2 * 3600 + 30 * 60) * 1000).toISOString();
+ const end = new Date().toISOString();
+ assert.equal(formatDuration(start, end), '2h 30m');
+ });
+
+ it('uses current time when endISO is null', () => {
+ // Start was 10s ago, no end → should return ~10s
+ const start = new Date(Date.now() - 10 * 1000).toISOString();
+ const result = formatDuration(start, null);
+ assert.match(result, /^\d+s$/);
+ });
+});
+
+// ── sortExecutionsDesc: inline implementation ─────────────────────────────────
+
+function sortExecutionsDesc(executions) {
+ return [...executions].sort((a, b) =>
+ new Date(b.started_at).getTime() - new Date(a.started_at).getTime(),
+ );
+}
+
+// ── Tests: sortExecutionsDesc ─────────────────────────────────────────────────
+
+describe('sortExecutionsDesc', () => {
+ it('sorts executions newest first', () => {
+ const execs = [
+ { id: 'a', started_at: '2024-01-01T00:00:00Z' },
+ { id: 'b', started_at: '2024-01-03T00:00:00Z' },
+ { id: 'c', started_at: '2024-01-02T00:00:00Z' },
+ ];
+ const result = sortExecutionsDesc(execs);
+ assert.equal(result[0].id, 'b');
+ assert.equal(result[1].id, 'c');
+ assert.equal(result[2].id, 'a');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(sortExecutionsDesc([]), []);
+ });
+
+ it('does not mutate the original array', () => {
+ const execs = [
+ { id: 'a', started_at: '2024-01-01T00:00:00Z' },
+ { id: 'b', started_at: '2024-01-03T00:00:00Z' },
+ ];
+ const copy = [execs[0], execs[1]];
+ sortExecutionsDesc(execs);
+ assert.deepEqual(execs, copy);
+ });
+
+ it('returns single-element array unchanged', () => {
+ const execs = [{ id: 'a', started_at: '2024-01-01T00:00:00Z' }];
+ assert.equal(sortExecutionsDesc(execs)[0].id, 'a');
+ });
+});
+
+// ── Tests: filterRunningTasks (explicit empty input check) ─────────────────────
+
+describe('filterRunningTasks (empty input)', () => {
+ it('returns [] for empty input', () => {
+ assert.deepEqual(filterRunningTasks([]), []);
+ });
+});
+
+// ── Tests: extractLogLines ────────────────────────────────────────────────────
+
+describe('extractLogLines', () => {
+ it('returns lines unchanged when count is below max', () => {
+ const lines = ['a', 'b', 'c'];
+ assert.deepEqual(extractLogLines(lines, 500), lines);
+ });
+
+ it('returns lines unchanged when count equals max', () => {
+ const lines = Array.from({ length: 500 }, (_, i) => `line${i}`);
+ assert.equal(extractLogLines(lines, 500).length, 500);
+ assert.equal(extractLogLines(lines, 500)[0], 'line0');
+ });
+
+ it('truncates to last max lines when count exceeds max', () => {
+ const lines = Array.from({ length: 600 }, (_, i) => `line${i}`);
+ const result = extractLogLines(lines, 500);
+ assert.equal(result.length, 500);
+ assert.equal(result[0], 'line100');
+ assert.equal(result[499], 'line599');
+ });
+
+ it('uses default max of 500', () => {
+ const lines = Array.from({ length: 501 }, (_, i) => `line${i}`);
+ const result = extractLogLines(lines);
+ assert.equal(result.length, 500);
+ assert.equal(result[0], 'line1');
+ });
+
+ it('returns empty array for empty input', () => {
+ assert.deepEqual(extractLogLines([]), []);
+ });
+
+ it('does not mutate the original array', () => {
+ const lines = Array.from({ length: 600 }, (_, i) => `line${i}`);
+ const copy = [...lines];
+ extractLogLines(lines, 500);
+ assert.deepEqual(lines, copy);
+ });
+});
diff --git a/web/test/sort-tasks.test.mjs b/web/test/sort-tasks.test.mjs
new file mode 100644
index 0000000..fe47702
--- /dev/null
+++ b/web/test/sort-tasks.test.mjs
@@ -0,0 +1,88 @@
+// sort-tasks.test.mjs — TDD contract tests for sortTasksByDate
+//
+// sortTasksByDate is defined inline here to establish expected behaviour.
+// Once sortTasksByDate is exported from web/app.js or a shared module,
+// remove the inline definition and import it instead.
+//
+// Run with: node --test web/test/sort-tasks.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+
+// ── Implementation under contract ─────────────────────────────────────────────
+// Remove this block once sortTasksByDate is available from app.js.
+
+function sortTasksByDate(tasks) {
+ return [...tasks].sort((a, b) => {
+ if (!a.created_at && !b.created_at) return 0;
+ if (!a.created_at) return 1;
+ if (!b.created_at) return -1;
+ return new Date(a.created_at) - new Date(b.created_at);
+ });
+}
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(id, created_at, state = 'PENDING') {
+ return { id, name: `task-${id}`, state, created_at };
+}
+
+// ── Tests ──────────────────────────────────────────────────────────────────────
+
+describe('sortTasksByDate', () => {
+ it('sorts tasks oldest-first by created_at', () => {
+ const tasks = [
+ makeTask('c', '2026-03-06T12:00:00Z'),
+ makeTask('a', '2026-03-04T08:00:00Z'),
+ makeTask('b', '2026-03-05T10:00:00Z'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'a', 'oldest should be first');
+ assert.equal(result[1].id, 'b');
+ assert.equal(result[2].id, 'c', 'newest should be last');
+ });
+
+ it('returns a new array (does not mutate input)', () => {
+ const tasks = [
+ makeTask('b', '2026-03-05T10:00:00Z'),
+ makeTask('a', '2026-03-04T08:00:00Z'),
+ ];
+ const original = [...tasks];
+ const result = sortTasksByDate(tasks);
+ assert.notStrictEqual(result, tasks, 'should return a new array');
+ assert.deepEqual(tasks, original, 'input should not be mutated');
+ });
+
+ it('returns an empty array when given an empty array', () => {
+ assert.deepEqual(sortTasksByDate([]), []);
+ });
+
+ it('returns a single-element array unchanged', () => {
+ const tasks = [makeTask('x', '2026-03-01T00:00:00Z')];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result.length, 1);
+ assert.equal(result[0].id, 'x');
+ });
+
+ it('places tasks with null created_at after tasks with a date', () => {
+ const tasks = [
+ makeTask('no-date', null),
+ makeTask('has-date', '2026-03-01T00:00:00Z'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'has-date', 'task with date should come first');
+ assert.equal(result[1].id, 'no-date', 'task without date should come last');
+ });
+
+ it('works with mixed states (not just PENDING)', () => {
+ const tasks = [
+ makeTask('r', '2026-03-06T00:00:00Z', 'RUNNING'),
+ makeTask('p', '2026-03-04T00:00:00Z', 'PENDING'),
+ makeTask('q', '2026-03-05T00:00:00Z', 'QUEUED'),
+ ];
+ const result = sortTasksByDate(tasks);
+ assert.equal(result[0].id, 'p');
+ assert.equal(result[1].id, 'q');
+ assert.equal(result[2].id, 'r');
+ });
+});
diff --git a/web/test/task-actions.test.mjs b/web/test/task-actions.test.mjs
index 2df6523..36c0e8b 100644
--- a/web/test/task-actions.test.mjs
+++ b/web/test/task-actions.test.mjs
@@ -1,4 +1,4 @@
-// task-actions.test.mjs — button visibility logic for Cancel/Restart actions
+// task-actions.test.mjs — button visibility logic for Cancel/Restart/Resume actions
//
// Run with: node --test web/test/task-actions.test.mjs
@@ -7,16 +7,23 @@ import assert from 'node:assert/strict';
// ── Logic under test ──────────────────────────────────────────────────────────
-const RESTART_STATES = new Set(['FAILED', 'TIMED_OUT', 'CANCELLED']);
+const RESTART_STATES = new Set(['FAILED', 'CANCELLED']);
function getCardAction(state) {
if (state === 'PENDING') return 'run';
if (state === 'RUNNING') return 'cancel';
if (state === 'READY') return 'approve';
+ if (state === 'TIMED_OUT') return 'resume';
if (RESTART_STATES.has(state)) return 'restart';
return null;
}
+function getApiEndpoint(state) {
+ if (state === 'TIMED_OUT') return '/resume';
+ if (RESTART_STATES.has(state)) return '/run';
+ return null;
+}
+
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('task card action buttons', () => {
@@ -32,8 +39,8 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('FAILED'), 'restart');
});
- it('shows Restart button for TIMED_OUT', () => {
- assert.equal(getCardAction('TIMED_OUT'), 'restart');
+ it('shows Resume button for TIMED_OUT', () => {
+ assert.equal(getCardAction('TIMED_OUT'), 'resume');
});
it('shows Restart button for CANCELLED', () => {
@@ -56,3 +63,17 @@ describe('task card action buttons', () => {
assert.equal(getCardAction('BUDGET_EXCEEDED'), null);
});
});
+
+describe('task action API endpoints', () => {
+ it('TIMED_OUT uses /resume endpoint', () => {
+ assert.equal(getApiEndpoint('TIMED_OUT'), '/resume');
+ });
+
+ it('FAILED uses /run endpoint', () => {
+ assert.equal(getApiEndpoint('FAILED'), '/run');
+ });
+
+ it('CANCELLED uses /run endpoint', () => {
+ assert.equal(getApiEndpoint('CANCELLED'), '/run');
+ });
+});