diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 103 | ||||
| -rw-r--r-- | web/index.html | 10 | ||||
| -rw-r--r-- | web/style.css | 166 | ||||
| -rw-r--r-- | web/test/active-tasks-tab.test.mjs | 68 | ||||
| -rw-r--r-- | web/test/delete-button.test.mjs | 60 | ||||
| -rw-r--r-- | web/test/filter-tabs.test.mjs | 90 | ||||
| -rw-r--r-- | web/test/running-view.test.mjs | 295 | ||||
| -rw-r--r-- | web/test/sort-tasks.test.mjs | 88 | ||||
| -rw-r--r-- | web/test/task-actions.test.mjs | 29 |
9 files changed, 846 insertions, 63 deletions
@@ -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'); + }); +}); |
