summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
authorClaudomator Agent <agent@claudomator>2026-03-11 19:05:12 +0000
committerClaudomator Agent <agent@claudomator>2026-03-11 19:05:12 +0000
commit55c20922cc7a671787fe94fdd53a7eb72ebd2596 (patch)
tree01492aec1a365348ce21281fef4e36b4372661e3 /web/test
parent1bcc40f7fd83bc603201b14577eebe5a482ba68e (diff)
feat: add Stats tab with task distribution and execution health metrics
- Export computeTaskStats and computeExecutionStats from app.js - Add renderStatsPanel with state count grid, KPI row (total/success-rate/cost/avg-duration), and outcome bar chart - Wire stats tab into switchTab and poll for live refresh - Add Stats tab button and panel to index.html - Add CSS for .stats-counts, .stats-kpis, .stats-bar-chart using existing state color variables - Add docs/stats-tab-plan.md with component structure and data flow - 14 new unit tests in web/test/stats.test.mjs (140 total, all passing) No backend changes — derives all metrics from existing /api/tasks and /api/executions endpoints. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/test')
-rw-r--r--web/test/stats.test.mjs153
1 files changed, 153 insertions, 0 deletions
diff --git a/web/test/stats.test.mjs b/web/test/stats.test.mjs
new file mode 100644
index 0000000..a7fe657
--- /dev/null
+++ b/web/test/stats.test.mjs
@@ -0,0 +1,153 @@
+// stats.test.mjs — Unit tests for stats tab computation functions.
+//
+// Run with: node --test web/test/stats.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { computeTaskStats, computeExecutionStats } from '../app.js';
+
+// ── Helpers ────────────────────────────────────────────────────────────────────
+
+function makeTask(state) {
+ return { id: state, name: `task-${state}`, state };
+}
+
+function makeExec(state, costUSD = 0, durationMs = null) {
+ const started = new Date('2024-01-01T10:00:00Z');
+ const finished = durationMs != null
+ ? new Date(started.getTime() + durationMs).toISOString()
+ : null;
+ return {
+ id: `exec-${Math.random()}`,
+ task_id: 'task-1',
+ task_name: 'Test Task',
+ state,
+ started_at: started.toISOString(),
+ finished_at: finished,
+ duration_ms: durationMs,
+ cost_usd: costUSD,
+ exit_code: state === 'completed' ? 0 : 1,
+ };
+}
+
+// ── computeTaskStats ───────────────────────────────────────────────────────────
+
+describe('computeTaskStats', () => {
+ it('groups tasks by state', () => {
+ const tasks = [
+ makeTask('RUNNING'), makeTask('RUNNING'),
+ makeTask('PENDING'),
+ makeTask('COMPLETED'), makeTask('COMPLETED'), makeTask('COMPLETED'),
+ ];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 2);
+ assert.equal(stats.byState.PENDING, 1);
+ assert.equal(stats.byState.COMPLETED, 3);
+ });
+
+ it('only includes states that have tasks', () => {
+ const tasks = [makeTask('RUNNING')];
+ const stats = computeTaskStats(tasks);
+ assert.equal(stats.byState.RUNNING, 1);
+ assert.equal(stats.byState.PENDING, undefined);
+ assert.equal(stats.byState.COMPLETED, undefined);
+ });
+
+ it('returns empty byState for empty task list', () => {
+ const stats = computeTaskStats([]);
+ assert.deepEqual(stats.byState, {});
+ });
+
+ it('counts all distinct states correctly', () => {
+ const states = ['PENDING', 'QUEUED', 'RUNNING', 'READY', 'COMPLETED',
+ 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED', 'BLOCKED'];
+ const tasks = states.map(makeTask);
+ const stats = computeTaskStats(tasks);
+ for (const s of states) {
+ assert.equal(stats.byState[s], 1, `expected count 1 for state ${s}`);
+ }
+ });
+});
+
+// ── computeExecutionStats ──────────────────────────────────────────────────────
+
+describe('computeExecutionStats', () => {
+ it('returns zeros for empty executions', () => {
+ const stats = computeExecutionStats([]);
+ assert.equal(stats.total, 0);
+ assert.equal(stats.successRate, 0);
+ assert.equal(stats.totalCostUSD, 0);
+ assert.equal(stats.avgDurationMs, null);
+ assert.deepEqual(stats.byOutcome, {});
+ });
+
+ it('calculates total correctly', () => {
+ const execs = [makeExec('completed'), makeExec('failed'), makeExec('cancelled')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.total, 3);
+ });
+
+ it('calculates success rate as fraction of completed out of total', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0.75);
+ });
+
+ it('returns success rate 0 when all executions failed', () => {
+ const execs = [makeExec('failed'), makeExec('failed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 0);
+ });
+
+ it('returns success rate 1 when all executions completed', () => {
+ const execs = [makeExec('completed'), makeExec('completed')];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.successRate, 1);
+ });
+
+ it('sums total cost correctly', () => {
+ const execs = [makeExec('completed', 0.5), makeExec('completed', 0.25), makeExec('failed', 0.1)];
+ const stats = computeExecutionStats(execs);
+ assert.ok(Math.abs(stats.totalCostUSD - 0.85) < 0.0001, `expected 0.85, got ${stats.totalCostUSD}`);
+ });
+
+ it('calculates average duration from executions with duration_ms', () => {
+ const execs = [
+ makeExec('completed', 0, 60000), // 1 min
+ makeExec('completed', 0, 120000), // 2 min
+ makeExec('failed', 0, 30000), // 30 sec
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 70000); // (60000+120000+30000)/3
+ });
+
+ it('ignores executions without duration_ms in avg calculation', () => {
+ const execs = [
+ makeExec('completed', 0, 60000),
+ makeExec('running', 0, null), // still running, no duration
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, 60000);
+ });
+
+ it('returns null avgDurationMs when no executions have duration_ms', () => {
+ const execs = [makeExec('running', 0, null)];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.avgDurationMs, null);
+ });
+
+ it('groups executions by outcome in byOutcome', () => {
+ const execs = [
+ makeExec('completed'), makeExec('completed'),
+ makeExec('failed'),
+ makeExec('cancelled'),
+ ];
+ const stats = computeExecutionStats(execs);
+ assert.equal(stats.byOutcome.completed, 2);
+ assert.equal(stats.byOutcome.failed, 1);
+ assert.equal(stats.byOutcome.cancelled, 1);
+ });
+});