summaryrefslogtreecommitdiff
path: root/web
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-03-13 03:17:04 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-03-13 03:17:04 +0000
commitfe414fac958330c2302d9175d66e1b338e5b1864 (patch)
treeef4941f5d01e84e7868e6b92bd0e6cecdcc2a64f /web
parentd5f83f8662c9f9c0fb52b206b06d4dd54a7788b4 (diff)
parent55c20922cc7a671787fe94fdd53a7eb72ebd2596 (diff)
merge: resolve conflicts with local/master (stats tab + summary styles)
Keep file-based summary approach (CLAUDOMATOR_SUMMARY_FILE) from HEAD. Combine Q&A History and Stats tab CSS from both branches. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web')
-rw-r--r--web/app.js266
-rw-r--r--web/index.html2
-rw-r--r--web/style.css182
-rw-r--r--web/test/stats.test.mjs153
4 files changed, 603 insertions, 0 deletions
diff --git a/web/app.js b/web/app.js
index dddaeab..3121f28 100644
--- a/web/app.js
+++ b/web/app.js
@@ -301,6 +301,57 @@ export function setTaskFilterTab(tab) {
localStorage.setItem('taskFilterTab', tab);
}
+// ── Stats computations ─────────────────────────────────────────────────────────
+
+/**
+ * Computes task state distribution from a task array.
+ * Returns { byState: { [state]: count } } — only states with tasks are included.
+ */
+export function computeTaskStats(tasks) {
+ const byState = {};
+ for (const t of tasks) {
+ byState[t.state] = (byState[t.state] || 0) + 1;
+ }
+ return { byState };
+}
+
+/**
+ * Computes execution health metrics from a RecentExecution array.
+ * Returns:
+ * { total, successRate, totalCostUSD, avgDurationMs, byOutcome }
+ * where successRate is a fraction (0–1), avgDurationMs is null if no durations.
+ */
+export function computeExecutionStats(executions) {
+ if (executions.length === 0) {
+ return { total: 0, successRate: 0, totalCostUSD: 0, avgDurationMs: null, byOutcome: {} };
+ }
+
+ let completed = 0;
+ let totalCost = 0;
+ let durationSum = 0;
+ let durationCount = 0;
+ const byOutcome = {};
+
+ for (const e of executions) {
+ const outcome = e.state || 'unknown';
+ byOutcome[outcome] = (byOutcome[outcome] || 0) + 1;
+ if (outcome === 'completed') completed++;
+ totalCost += e.cost_usd || 0;
+ if (e.duration_ms != null) {
+ durationSum += e.duration_ms;
+ durationCount++;
+ }
+ }
+
+ return {
+ total: executions.length,
+ successRate: executions.length > 0 ? completed / executions.length : 0,
+ totalCostUSD: totalCost,
+ avgDurationMs: durationCount > 0 ? Math.round(durationSum / durationCount) : null,
+ byOutcome,
+ };
+}
+
export function updateFilterTabs() {
const current = getTaskFilterTab();
document.querySelectorAll('.filter-tab[data-filter]').forEach(el => {
@@ -817,6 +868,11 @@ async function handleStartNextTask(btn) {
// ── Polling ───────────────────────────────────────────────────────────────────
+function isStatsTabActive() {
+ const panel = document.querySelector('[data-panel="stats"]');
+ return panel && !panel.hasAttribute('hidden');
+}
+
async function poll() {
try {
const tasks = await fetchTasks();
@@ -832,6 +888,11 @@ async function poll() {
if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
});
}
+ if (isStatsTabActive()) {
+ fetchRecentExecutions(BASE_PATH, fetch)
+ .then(execs => renderStatsPanel(tasks, execs))
+ .catch(() => {});
+ }
} catch {
document.querySelector('.task-list').innerHTML =
'<div id="loading">Could not reach server.</div>';
@@ -1363,6 +1424,16 @@ function renderTaskPanel(task, executions) {
overview.appendChild(overviewGrid);
content.appendChild(overview);
+ // ── Summary ──
+ if (task.summary) {
+ const summarySection = makeSection("Summary");
+ const summaryText = document.createElement("div");
+ summaryText.className = "task-summary-text";
+ summaryText.textContent = task.summary;
+ summarySection.appendChild(summaryText);
+ content.appendChild(summarySection);
+ }
+
// ── Agent Config ──
const a = task.agent || {};
const agentSection = makeSection('Agent Config');
@@ -1390,6 +1461,34 @@ function renderTaskPanel(task, executions) {
agentSection.appendChild(agentGrid);
content.appendChild(agentSection);
+ // ── Q&A History ──
+ let interactions = [];
+ if (task.interactions) {
+ try { interactions = JSON.parse(task.interactions); } catch {}
+ }
+ if (interactions.length > 0) {
+ const qaSection = makeSection("Q&A History");
+ const timeline = document.createElement("div");
+ timeline.className = "qa-timeline";
+ for (const item of interactions) {
+ const entry = document.createElement("div");
+ entry.className = `qa-item qa-${item.type}`;
+ const label = document.createElement("span");
+ label.className = "qa-label";
+ label.textContent = item.type === "question" ? "Agent asked:" : "User answered:";
+ const text = document.createElement("div");
+ text.className = "qa-content";
+ text.textContent = item.content;
+ const ts = document.createElement("span");
+ ts.className = "qa-timestamp";
+ ts.textContent = item.timestamp ? formatDate(item.timestamp) : "";
+ entry.append(label, text, ts);
+ timeline.appendChild(entry);
+ }
+ qaSection.appendChild(timeline);
+ content.appendChild(qaSection);
+ }
+
// ── Execution Settings ──
const settingsSection = makeSection('Execution Settings');
const settingsGrid = document.createElement('div');
@@ -1925,6 +2024,163 @@ function renderRunningHistory(executions) {
histEl.appendChild(table);
}
+// ── Stats rendering ───────────────────────────────────────────────────────────
+
+// State display order for the task overview grid.
+const STATS_STATE_ORDER = [
+ 'RUNNING', 'QUEUED', 'READY', 'BLOCKED',
+ 'PENDING', 'COMPLETED', 'FAILED', 'TIMED_OUT', 'CANCELLED', 'BUDGET_EXCEEDED',
+];
+
+function formatDurationMs(ms) {
+ if (ms == null) return '—';
+ const s = Math.round(ms / 1000);
+ if (s < 60) return `${s}s`;
+ const m = Math.floor(s / 60);
+ const rs = s % 60;
+ if (m < 60) return rs > 0 ? `${m}m ${rs}s` : `${m}m`;
+ const h = Math.floor(m / 60);
+ const rm = m % 60;
+ return rm > 0 ? `${h}h ${rm}m` : `${h}h`;
+}
+
+function renderStatsPanel(tasks, executions) {
+ const panel = document.querySelector('[data-panel="stats"]');
+ if (!panel) return;
+
+ const taskStats = computeTaskStats(tasks);
+ const execStats = computeExecutionStats(executions);
+
+ panel.innerHTML = '';
+
+ // ── Task Overview ──────────────────────────────────────────────────────────
+ const taskSection = document.createElement('div');
+ taskSection.className = 'stats-section';
+
+ const taskHeading = document.createElement('h2');
+ taskHeading.textContent = 'Task Overview';
+ taskSection.appendChild(taskHeading);
+
+ const countsGrid = document.createElement('div');
+ countsGrid.className = 'stats-counts';
+
+ const orderedStates = STATS_STATE_ORDER.filter(s => taskStats.byState[s] > 0);
+ const otherStates = Object.keys(taskStats.byState).filter(s => !STATS_STATE_ORDER.includes(s));
+
+ for (const state of [...orderedStates, ...otherStates]) {
+ const count = taskStats.byState[state] || 0;
+ if (count === 0) continue;
+ const box = document.createElement('div');
+ box.className = 'stats-count-box';
+ box.dataset.state = state;
+
+ const num = document.createElement('span');
+ num.className = 'stats-count-number';
+ num.textContent = String(count);
+
+ const label = document.createElement('span');
+ label.className = 'stats-count-label';
+ label.textContent = state.replace(/_/g, ' ');
+
+ box.appendChild(num);
+ box.appendChild(label);
+ countsGrid.appendChild(box);
+ }
+
+ if (orderedStates.length === 0 && otherStates.length === 0) {
+ const empty = document.createElement('p');
+ empty.className = 'task-meta';
+ empty.textContent = 'No tasks yet.';
+ countsGrid.appendChild(empty);
+ }
+
+ taskSection.appendChild(countsGrid);
+ panel.appendChild(taskSection);
+
+ // ── Execution Health ───────────────────────────────────────────────────────
+ const execSection = document.createElement('div');
+ execSection.className = 'stats-section';
+
+ const execHeading = document.createElement('h2');
+ execHeading.textContent = 'Executions (Last 24h)';
+ execSection.appendChild(execHeading);
+
+ const kpisRow = document.createElement('div');
+ kpisRow.className = 'stats-kpis';
+
+ const kpis = [
+ { label: 'Total Runs', value: String(execStats.total) },
+ { label: 'Success Rate', value: execStats.total > 0 ? `${Math.round(execStats.successRate * 100)}%` : '—' },
+ { label: 'Total Cost', value: execStats.totalCostUSD > 0 ? `$${execStats.totalCostUSD.toFixed(2)}` : '$0.00' },
+ { label: 'Avg Duration', value: formatDurationMs(execStats.avgDurationMs) },
+ ];
+
+ for (const kpi of kpis) {
+ const box = document.createElement('div');
+ box.className = 'stats-kpi-box';
+
+ const val = document.createElement('span');
+ val.className = 'stats-kpi-value';
+ val.textContent = kpi.value;
+
+ const lbl = document.createElement('span');
+ lbl.className = 'stats-kpi-label';
+ lbl.textContent = kpi.label;
+
+ box.appendChild(val);
+ box.appendChild(lbl);
+ kpisRow.appendChild(box);
+ }
+ execSection.appendChild(kpisRow);
+
+ // Bar chart of outcome distribution.
+ if (execStats.total > 0) {
+ const chartSection = document.createElement('div');
+ chartSection.className = 'stats-bar-chart';
+
+ const chartLabel = document.createElement('p');
+ chartLabel.className = 'stats-bar-chart-label';
+ chartLabel.textContent = 'Outcome breakdown';
+ chartSection.appendChild(chartLabel);
+
+ const bars = document.createElement('div');
+ bars.className = 'stats-bars';
+
+ for (const [outcome, count] of Object.entries(execStats.byOutcome)) {
+ const pct = (count / execStats.total) * 100;
+ const row = document.createElement('div');
+ row.className = 'stats-bar-row';
+
+ const barLabel = document.createElement('span');
+ barLabel.className = 'stats-bar-row-label';
+ barLabel.textContent = outcome.replace(/_/g, ' ');
+
+ const barTrack = document.createElement('div');
+ barTrack.className = 'stats-bar-track';
+
+ const barFill = document.createElement('div');
+ barFill.className = 'stats-bar-fill';
+ barFill.dataset.state = outcome.toUpperCase();
+ barFill.style.width = `${pct.toFixed(1)}%`;
+
+ const barCount = document.createElement('span');
+ barCount.className = 'stats-bar-count';
+ barCount.textContent = `${count} (${Math.round(pct)}%)`;
+
+ barTrack.appendChild(barFill);
+ row.appendChild(barLabel);
+ row.appendChild(barTrack);
+ row.appendChild(barCount);
+ bars.appendChild(row);
+ }
+
+ chartSection.appendChild(bars);
+ execSection.appendChild(chartSection);
+ }
+
+ panel.appendChild(execSection);
+}
+
// ── Tab switching ─────────────────────────────────────────────────────────────
function switchTab(name) {
@@ -1958,6 +2214,16 @@ function switchTab(name) {
if (histEl) histEl.innerHTML = '<p class="task-meta">Could not load execution history.</p>';
});
}
+
+ if (name === 'stats') {
+ Promise.all([
+ fetchTasks(),
+ fetchRecentExecutions(BASE_PATH, fetch),
+ ]).then(([tasks, execs]) => renderStatsPanel(tasks, execs)).catch(() => {
+ const panel = document.querySelector('[data-panel="stats"]');
+ if (panel) panel.innerHTML = '<p class="task-meta">Could not load stats.</p>';
+ });
+ }
}
// ── Boot ──────────────────────────────────────────────────────────────────────
diff --git a/web/index.html b/web/index.html
index 0b4ee35..438216f 100644
--- a/web/index.html
+++ b/web/index.html
@@ -18,6 +18,7 @@
<button class="tab active" data-tab="tasks">Tasks</button>
<button class="tab" data-tab="active">Active</button>
<button class="tab" data-tab="running">Running</button>
+ <button class="tab" data-tab="stats">Stats</button>
</nav>
<main id="app">
<div data-panel="tasks">
@@ -38,6 +39,7 @@
<div class="running-current"></div>
<div class="running-history"></div>
</div>
+ <div data-panel="stats" hidden></div>
</main>
<dialog id="task-modal">
diff --git a/web/style.css b/web/style.css
index 342e0b3..67e4962 100644
--- a/web/style.css
+++ b/web/style.css
@@ -1170,6 +1170,29 @@ dialog label select:focus {
margin-top: 0.5rem;
}
+.task-summary-text {
+ padding: 0.75rem 1rem;
+ background: var(--surface-1, #f8f9fa);
+ border-radius: 6px;
+ line-height: 1.5;
+ white-space: pre-wrap;
+}
+.qa-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+.qa-item {
+ padding: 0.5rem 0.75rem;
+ border-left: 3px solid;
+ border-radius: 0 4px 4px 0;
+}
+.qa-question { border-color: #3b82f6; background: rgba(59,130,246,0.05); }
+.qa-answer { border-color: #10b981; background: rgba(16,185,129,0.05); }
+.qa-label { font-weight: 600; font-size: 0.85em; display: block; margin-bottom: 0.25rem; }
+.qa-content { white-space: pre-wrap; }
+.qa-timestamp { font-size: 0.75em; color: var(--text-muted, #6b7280); margin-top: 0.25rem; display: block; }
+
.inline-edit-success {
color: var(--state-completed);
font-size: 0.82rem;
@@ -1214,3 +1237,162 @@ dialog label select:focus {
color: var(--accent, #60a5fa);
font-style: italic;
}
+
+/* ── Stats tab ────────────────────────────────────────────────────────────── */
+
+[data-panel="stats"] {
+ padding: 1rem 1.5rem;
+ max-width: 900px;
+}
+
+.stats-section {
+ margin-bottom: 2rem;
+}
+
+.stats-section h2 {
+ font-size: 1rem;
+ font-weight: 600;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin: 0 0 0.75rem;
+}
+
+/* Task state count boxes */
+.stats-counts {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.stats-count-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 0.75rem 1.25rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ min-width: 80px;
+}
+
+.stats-count-box[data-state="RUNNING"] { border-color: var(--state-running); }
+.stats-count-box[data-state="QUEUED"] { border-color: var(--state-queued); }
+.stats-count-box[data-state="READY"] { border-color: var(--state-running); }
+.stats-count-box[data-state="BLOCKED"] { border-color: var(--state-blocked); }
+.stats-count-box[data-state="PENDING"] { border-color: var(--state-pending); }
+.stats-count-box[data-state="COMPLETED"] { border-color: var(--state-completed); }
+.stats-count-box[data-state="FAILED"] { border-color: var(--state-failed); }
+.stats-count-box[data-state="TIMED_OUT"] { border-color: var(--state-timed-out); }
+.stats-count-box[data-state="CANCELLED"] { border-color: var(--state-cancelled); }
+.stats-count-box[data-state="BUDGET_EXCEEDED"] { border-color: var(--state-budget-exceeded); }
+
+.stats-count-number {
+ font-size: 1.75rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-count-label {
+ font-size: 0.7rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* KPI row */
+.stats-kpis {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 0.75rem;
+ margin-bottom: 1.25rem;
+}
+
+@media (max-width: 600px) {
+ .stats-kpis {
+ grid-template-columns: repeat(2, 1fr);
+ }
+}
+
+.stats-kpi-box {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding: 1rem;
+ border-radius: 8px;
+ background: var(--card-bg);
+ border: 1px solid var(--border);
+ text-align: center;
+}
+
+.stats-kpi-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ line-height: 1;
+}
+
+.stats-kpi-label {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ margin-top: 0.25rem;
+ white-space: nowrap;
+}
+
+/* Outcome bar chart */
+.stats-bar-chart-label {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ margin: 0 0 0.5rem;
+}
+
+.stats-bars {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+}
+
+.stats-bar-row {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.stats-bar-row-label {
+ font-size: 0.78rem;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ width: 120px;
+ flex-shrink: 0;
+ white-space: nowrap;
+}
+
+.stats-bar-track {
+ flex: 1;
+ height: 16px;
+ background: var(--border);
+ border-radius: 4px;
+ overflow: hidden;
+}
+
+.stats-bar-fill {
+ height: 100%;
+ border-radius: 4px;
+ background: var(--state-pending);
+ transition: width 0.3s ease;
+}
+
+.stats-bar-fill[data-state="COMPLETED"] { background: var(--state-completed); }
+.stats-bar-fill[data-state="FAILED"] { background: var(--state-failed); }
+.stats-bar-fill[data-state="CANCELLED"] { background: var(--state-cancelled); }
+.stats-bar-fill[data-state="TIMED_OUT"] { background: var(--state-timed-out); }
+.stats-bar-fill[data-state="BUDGET_EXCEEDED"] { background: var(--state-budget-exceeded); }
+.stats-bar-fill[data-state="RUNNING"] { background: var(--state-running); }
+
+.stats-bar-count {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ width: 80px;
+ flex-shrink: 0;
+}
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);
+ });
+});