diff options
Diffstat (limited to 'web')
| -rw-r--r-- | web/app.js | 52 | ||||
| -rw-r--r-- | web/style.css | 40 | ||||
| -rw-r--r-- | web/test/changestats.test.mjs | 125 |
3 files changed, 217 insertions, 0 deletions
@@ -74,6 +74,24 @@ function formatDate(iso) { }); } +// Returns formatted string for changestats, e.g. "5 files, +127 -43". +// Returns empty string for null/undefined input. +export function formatChangestats(stats) { + if (stats == null) return ''; + return `${stats.files_changed} files, +${stats.lines_added} -${stats.lines_removed}`; +} + +// Returns a <span class="changestats-badge"> element for the given stats, +// or null if stats is null/undefined. +// Accepts an optional doc parameter for testability (defaults to document). +export function renderChangestatsBadge(stats, doc = (typeof document !== 'undefined' ? document : null)) { + if (stats == null || doc == null) return null; + const span = doc.createElement('span'); + span.className = 'changestats-badge'; + span.textContent = formatChangestats(stats); + return span; +} + function createTaskCard(task) { const card = document.createElement('div'); card.className = 'task-card'; @@ -118,6 +136,13 @@ function createTaskCard(task) { card.appendChild(desc); } + // Changestats badge for COMPLETED/READY tasks + const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); + if (CHANGESTATS_STATES.has(task.state) && task.changestats != null) { + const csBadge = renderChangestatsBadge(task.changestats); + if (csBadge) card.appendChild(csBadge); + } + // Footer: action buttons based on state // Interrupted states (CANCELLED, FAILED, BUDGET_EXCEEDED) show both Resume and Restart. // TIMED_OUT shows Resume only. Others show a single action. @@ -1690,6 +1715,33 @@ function renderTaskPanel(task, executions) { exitEl.textContent = `exit: ${exec.ExitCode ?? '—'}`; row.appendChild(exitEl); + if (exec.Changestats != null) { + const csBadge = renderChangestatsBadge(exec.Changestats); + if (csBadge) row.appendChild(csBadge); + } + + if (exec.Commits && exec.Commits.length > 0) { + const commitList = document.createElement('div'); + commitList.className = 'execution-commits'; + for (const commit of exec.Commits) { + const item = document.createElement('div'); + item.className = 'commit-item'; + + const hash = document.createElement('span'); + hash.className = 'commit-hash'; + hash.textContent = commit.hash.slice(0, 7); + item.appendChild(hash); + + const msg = document.createElement('span'); + msg.className = 'commit-msg'; + msg.textContent = commit.message; + item.appendChild(msg); + + commitList.appendChild(item); + } + row.appendChild(commitList); + } + const logsBtn = document.createElement('button'); logsBtn.className = 'btn-view-logs'; logsBtn.textContent = 'View Logs'; diff --git a/web/style.css b/web/style.css index 31f929e..e7d1de4 100644 --- a/web/style.css +++ b/web/style.css @@ -793,6 +793,39 @@ dialog label select:focus { flex-wrap: wrap; } +.execution-commits { + width: 100%; + margin-top: 0.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.8rem; + color: var(--text-muted); + border-top: 1px solid var(--border-light); + padding-top: 0.5rem; +} + +.commit-item { + display: flex; + gap: 0.5rem; + align-items: baseline; +} + +.commit-hash { + font-family: var(--font-mono); + color: var(--text); + background: var(--bg-hover); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.75rem; +} + +.commit-msg { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .execution-id { font-family: monospace; font-size: 0.72rem; @@ -823,6 +856,13 @@ dialog label select:focus { white-space: nowrap; } +.changestats-badge { + font-family: monospace; + font-size: 0.72rem; + color: var(--text-muted); + white-space: nowrap; +} + .btn-view-logs { font-size: 0.72rem; font-weight: 600; diff --git a/web/test/changestats.test.mjs b/web/test/changestats.test.mjs new file mode 100644 index 0000000..5363812 --- /dev/null +++ b/web/test/changestats.test.mjs @@ -0,0 +1,125 @@ +// changestats.test.mjs — Unit tests for changestats display functions. +// +// Run with: node --test web/test/changestats.test.mjs + +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { formatChangestats, renderChangestatsBadge } from '../app.js'; + +// ── Mock DOM ─────────────────────────────────────────────────────────────────── + +function makeDoc() { + return { + createElement(tag) { + const el = { + tag, + className: '', + textContent: '', + children: [], + appendChild(child) { this.children.push(child); return child; }, + }; + return el; + }, + }; +} + +// ── formatChangestats ────────────────────────────────────────────────────────── + +describe('formatChangestats', () => { + it('formats valid stats as "N files, +A -R"', () => { + const result = formatChangestats({ files_changed: 5, lines_added: 127, lines_removed: 43 }); + assert.equal(result, '5 files, +127 -43'); + }); + + it('returns empty string for null', () => { + const result = formatChangestats(null); + assert.equal(result, ''); + }); + + it('returns empty string for undefined', () => { + const result = formatChangestats(undefined); + assert.equal(result, ''); + }); + + it('formats zero values correctly', () => { + const result = formatChangestats({ files_changed: 0, lines_added: 0, lines_removed: 0 }); + assert.equal(result, '0 files, +0 -0'); + }); + + it('formats single file correctly', () => { + const result = formatChangestats({ files_changed: 1, lines_added: 10, lines_removed: 2 }); + assert.equal(result, '1 files, +10 -2'); + }); +}); + +// ── renderChangestatsBadge ───────────────────────────────────────────────────── + +describe('renderChangestatsBadge', () => { + it('returns element with class changestats-badge for valid stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc); + assert.ok(el, 'element should not be null'); + assert.equal(el.className, 'changestats-badge'); + }); + + it('returns element with correct text content', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge({ files_changed: 5, lines_added: 127, lines_removed: 43 }, doc); + assert.equal(el.textContent, '5 files, +127 -43'); + }); + + it('returns null for null stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge(null, doc); + assert.equal(el, null); + }); + + it('returns null for undefined stats', () => { + const doc = makeDoc(); + const el = renderChangestatsBadge(undefined, doc); + assert.equal(el, null); + }); +}); + +// ── State-based visibility ──────────────────────────────────────────────────── +// +// Changestats badge should appear on COMPLETED (and READY) tasks that have +// changestats data, and must not appear on QUEUED tasks. + +const CHANGESTATS_STATES = new Set(['COMPLETED', 'READY']); + +function shouldShowChangestats(task) { + return CHANGESTATS_STATES.has(task.state) && task.changestats != null; +} + +describe('changestats badge visibility by task state', () => { + it('COMPLETED task with changestats shows badge', () => { + const task = { state: 'COMPLETED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } }; + assert.equal(shouldShowChangestats(task), true); + }); + + it('READY task with changestats shows badge', () => { + const task = { state: 'READY', changestats: { files_changed: 1, lines_added: 5, lines_removed: 2 } }; + assert.equal(shouldShowChangestats(task), true); + }); + + it('QUEUED task hides changestats', () => { + const task = { state: 'QUEUED', changestats: { files_changed: 3, lines_added: 50, lines_removed: 10 } }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('COMPLETED task without changestats hides badge', () => { + const task = { state: 'COMPLETED', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('RUNNING task hides changestats', () => { + const task = { state: 'RUNNING', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); + + it('PENDING task hides changestats', () => { + const task = { state: 'PENDING', changestats: null }; + assert.equal(shouldShowChangestats(task), false); + }); +}); |
