summaryrefslogtreecommitdiff
path: root/web/test
diff options
context:
space:
mode:
authorPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
committerPeter Stone <thepeterstone@gmail.com>2026-06-03 23:01:39 +0000
commit68cf1e24dd2ca2612b72babc4b35280cd3512f92 (patch)
tree14b73f21570a2f42ae729ca7e9676de6628d6819 /web/test
parentb28cfc6ff288d083f6c8e9c055b69bfcadbceccc (diff)
parent7388337d3be5cc7f65ef547e30b2e39884dd165b (diff)
merge: integrate oss branch — budget gating, MCP back-channel, events, loopback-only bind
Key features from OSS branch: - Budget gating: rolling per-provider spend caps with BUDGET_EXCEEDED task state - Agent MCP back-channel: runners mint tokens; /mcp endpoint resolves them - Chatbot MCP server at /chatbot/mcp (requires api_token in config) - Event timeline: unified event stream replacing ad-hoc question/summary flows - Loopback-only default bind (127.0.0.1:8484); external_bind_allowed=true to expose - repository_url required on task creation (enforces traceability) - Auto-checker: spawns verification task after each execution Conflict resolutions: - serve.go: keep cfg.Runners.XEnabled() conditional registration from main + agentRegistry from oss - config.go: keep RunnersConfig from main + BudgetConfig/ExternalBindAllowed from oss - server.go: handleStaticFiles (base-path rewrite) kept; deduplicated duplicate from both sides - executor/claude.go: accepted oss deletion (ClaudeRunner replaced by ContainerRunner) - container_test.go: added noopChannel + AgentChannel parameter to Run call Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'web/test')
-rw-r--r--web/test/budget.test.mjs58
-rw-r--r--web/test/event-timeline.test.mjs112
-rw-r--r--web/test/new-task-button.test.mjs24
-rw-r--r--web/test/stories.test.mjs164
4 files changed, 170 insertions, 188 deletions
diff --git a/web/test/budget.test.mjs b/web/test/budget.test.mjs
new file mode 100644
index 0000000..35d5256
--- /dev/null
+++ b/web/test/budget.test.mjs
@@ -0,0 +1,58 @@
+// budget.test.mjs — Unit tests for budget headroom display.
+//
+// Run with: node --test web/test/budget.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatBudgetHeadroom, renderBudgetHeadroom } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatBudgetHeadroom', () => {
+ it('formats a limited provider with percent and dollars', () => {
+ assert.equal(
+ formatBudgetHeadroom({ provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 }),
+ 'Claude: 60% left ($6.00 of $10.00)',
+ );
+ });
+
+ it('returns empty string for unlimited providers', () => {
+ assert.equal(formatBudgetHeadroom({ provider: 'local', limited: false }), '');
+ assert.equal(formatBudgetHeadroom(null), '');
+ });
+});
+
+describe('renderBudgetHeadroom', () => {
+ it('renders one chip per limited provider, skipping unlimited', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'claude', limited: true, limit_usd: 10, remaining_usd: 6, fraction_remaining: 0.6 },
+ { provider: 'local', limited: false },
+ ], makeDoc());
+ assert.equal(wrap.children.length, 1);
+ assert.equal(wrap.children[0].className, 'budget-chip');
+ assert.match(wrap.children[0].textContent, /Claude: 60% left/);
+ });
+
+ it('flags providers under 20% remaining with --low', () => {
+ const wrap = renderBudgetHeadroom([
+ { provider: 'gemini', limited: true, limit_usd: 5, remaining_usd: 0.5, fraction_remaining: 0.1 },
+ ], makeDoc());
+ assert.equal(wrap.children[0].className, 'budget-chip budget-chip--low');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderBudgetHeadroom([], null), null);
+ });
+});
diff --git a/web/test/event-timeline.test.mjs b/web/test/event-timeline.test.mjs
new file mode 100644
index 0000000..20ef61a
--- /dev/null
+++ b/web/test/event-timeline.test.mjs
@@ -0,0 +1,112 @@
+// event-timeline.test.mjs — Unit tests for the event timeline component.
+//
+// Run with: node --test web/test/event-timeline.test.mjs
+
+import { describe, it } from 'node:test';
+import assert from 'node:assert/strict';
+import { formatEventText, renderEventTimeline, fetchTaskEvents } from '../app.js';
+
+function makeDoc() {
+ return {
+ createElement(tag) {
+ return {
+ tag,
+ className: '',
+ textContent: '',
+ children: [],
+ appendChild(child) { this.children.push(child); return child; },
+ };
+ },
+ };
+}
+
+describe('formatEventText', () => {
+ it('formats state_change with from/to', () => {
+ assert.equal(
+ formatEventText({ kind: 'state_change', payload: { from: 'QUEUED', to: 'RUNNING' } }),
+ 'State: QUEUED → RUNNING',
+ );
+ });
+
+ it('formats summary text', () => {
+ assert.equal(
+ formatEventText({ kind: 'summary', payload: { text: 'did the thing' } }),
+ 'Summary: did the thing',
+ );
+ });
+
+ it('formats clarification_request and answer', () => {
+ assert.equal(
+ formatEventText({ kind: 'clarification_request', payload: { text: 'which branch?' } }),
+ 'Question: which branch?',
+ );
+ assert.equal(
+ formatEventText({ kind: 'clarification_answer', payload: { answer: 'main' } }),
+ 'Answer: main',
+ );
+ });
+
+ it('formats subtask_spawned by name', () => {
+ assert.equal(
+ formatEventText({ kind: 'subtask_spawned', payload: { name: 'write tests', subtask_id: 'x' } }),
+ 'Spawned subtask: write tests',
+ );
+ });
+
+ it('falls back to kind for unknown events', () => {
+ assert.equal(formatEventText({ kind: 'mystery', payload: {} }), 'mystery');
+ });
+
+ it('handles null/empty defensively', () => {
+ assert.equal(formatEventText(null), '');
+ assert.equal(formatEventText({ kind: 'agent_message', payload: { message: 'hi' } }), 'hi');
+ });
+});
+
+describe('renderEventTimeline', () => {
+ it('renders one item per event with actor and text', () => {
+ const events = [
+ { kind: 'state_change', actor: 'system', payload: { from: 'PENDING', to: 'QUEUED' } },
+ { kind: 'summary', actor: 'agent', payload: { text: 'done' } },
+ ];
+ const ul = renderEventTimeline(events, makeDoc());
+ assert.equal(ul.className, 'event-timeline');
+ assert.equal(ul.children.length, 2);
+ // each item has [actor, text]
+ assert.equal(ul.children[0].children[0].textContent, 'system');
+ assert.equal(ul.children[0].children[1].textContent, 'State: PENDING → QUEUED');
+ assert.equal(ul.children[1].children[1].textContent, 'Summary: done');
+ });
+
+ it('renders an empty-state item for no events', () => {
+ const ul = renderEventTimeline([], makeDoc());
+ assert.equal(ul.children.length, 1);
+ assert.equal(ul.children[0].className, 'event-timeline__empty');
+ });
+
+ it('returns null when doc is null', () => {
+ assert.equal(renderEventTimeline([], null), null);
+ });
+});
+
+describe('fetchTaskEvents', () => {
+ it('returns parsed array on success', async () => {
+ const stub = async () => ({ ok: true, json: async () => [{ kind: 'summary', payload: {} }] });
+ const events = await fetchTaskEvents('t1', stub);
+ assert.equal(events.length, 1);
+ });
+
+ it('returns [] on non-ok response', async () => {
+ const stub = async () => ({ ok: false, json: async () => ({}) });
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when fetch throws', async () => {
+ const stub = async () => { throw new Error('network'); };
+ assert.deepEqual(await fetchTaskEvents('t1', stub), []);
+ });
+
+ it('returns [] when no fetch implementation is available', async () => {
+ assert.deepEqual(await fetchTaskEvents('t1', null), []);
+ });
+});
diff --git a/web/test/new-task-button.test.mjs b/web/test/new-task-button.test.mjs
deleted file mode 100644
index 45a3548..0000000
--- a/web/test/new-task-button.test.mjs
+++ /dev/null
@@ -1,24 +0,0 @@
-// new-task-button.test.mjs — visibility contract for the New Task button
-//
-// The New Task button lives in the global header and must be visible on all tabs.
-// Run with: node --test web/test/new-task-button.test.mjs
-
-import { describe, it } from 'node:test';
-import assert from 'node:assert/strict';
-import { newTaskButtonShouldShowOnTab } from '../app.js';
-
-const ALL_TABS = ['tasks', 'active', 'running', 'stats'];
-
-describe('new task button visibility', () => {
- for (const tab of ALL_TABS) {
- it(`is visible on "${tab}" tab`, () => {
- assert.equal(newTaskButtonShouldShowOnTab(tab), true, `expected button to be visible on tab "${tab}"`);
- });
- }
-
- it('is visible on any unknown future tab', () => {
- assert.equal(newTaskButtonShouldShowOnTab('help'), true);
- assert.equal(newTaskButtonShouldShowOnTab('templates'), true);
- assert.equal(newTaskButtonShouldShowOnTab(''), true);
- });
-});
diff --git a/web/test/stories.test.mjs b/web/test/stories.test.mjs
deleted file mode 100644
index 263f202..0000000
--- a/web/test/stories.test.mjs
+++ /dev/null
@@ -1,164 +0,0 @@
-// stories.test.mjs — TDD tests for stories UI functions.
-//
-// Run with: node --test web/test/stories.test.mjs
-
-import { describe, it, beforeEach } from 'node:test';
-import assert from 'node:assert/strict';
-import { renderStoryCard, storyStatusLabel } from '../app.js';
-
-// ── Minimal DOM mock ──────────────────────────────────────────────────────────
-
-function makeMockDoc() {
- function createElement(tag) {
- return {
- tag,
- className: '',
- textContent: '',
- innerHTML: '',
- hidden: false,
- dataset: {},
- children: [],
- _listeners: {},
- style: {},
- appendChild(child) { this.children.push(child); return child; },
- prepend(...nodes) { this.children.unshift(...nodes); },
- append(...nodes) { nodes.forEach(n => this.children.push(n)); },
- addEventListener(ev, fn) { this._listeners[ev] = fn; },
- querySelector(sel) {
- const cls = sel.startsWith('.') ? sel.slice(1) : null;
- function search(el) {
- if (cls && el.className && el.className.split(' ').includes(cls)) return el;
- for (const c of el.children || []) {
- const found = search(c);
- if (found) return found;
- }
- return null;
- }
- return search(this);
- },
- querySelectorAll(sel) {
- const cls = sel.startsWith('.') ? sel.slice(1) : null;
- const results = [];
- function search(el) {
- if (cls && el.className && el.className.split(' ').includes(cls)) results.push(el);
- for (const c of el.children || []) search(c);
- }
- search(this);
- return results;
- },
- };
- }
- return { createElement };
-}
-
-function makeStory(overrides = {}) {
- return {
- id: 'story-1',
- name: 'Add login page',
- project_id: 'claudomator',
- branch_name: 'story/add-login-page',
- status: 'PENDING',
- created_at: '2026-03-25T10:00:00Z',
- updated_at: '2026-03-25T10:00:00Z',
- ...overrides,
- };
-}
-
-// ── storyStatusLabel ──────────────────────────────────────────────────────────
-
-describe('storyStatusLabel', () => {
- it('returns human-readable label for PENDING', () => {
- assert.equal(storyStatusLabel('PENDING'), 'Pending');
- });
-
- it('returns human-readable label for IN_PROGRESS', () => {
- assert.equal(storyStatusLabel('IN_PROGRESS'), 'In Progress');
- });
-
- it('returns human-readable label for SHIPPABLE', () => {
- assert.equal(storyStatusLabel('SHIPPABLE'), 'Shippable');
- });
-
- it('returns human-readable label for DEPLOYED', () => {
- assert.equal(storyStatusLabel('DEPLOYED'), 'Deployed');
- });
-
- it('returns human-readable label for VALIDATING', () => {
- assert.equal(storyStatusLabel('VALIDATING'), 'Validating');
- });
-
- it('returns human-readable label for REVIEW_READY', () => {
- assert.equal(storyStatusLabel('REVIEW_READY'), 'Review Ready');
- });
-
- it('returns human-readable label for NEEDS_FIX', () => {
- assert.equal(storyStatusLabel('NEEDS_FIX'), 'Needs Fix');
- });
-
- it('falls back to the raw status for unknown values', () => {
- assert.equal(storyStatusLabel('UNKNOWN_STATE'), 'UNKNOWN_STATE');
- });
-});
-
-// ── renderStoryCard ───────────────────────────────────────────────────────────
-
-describe('renderStoryCard', () => {
- let doc;
- beforeEach(() => { doc = makeMockDoc(); });
-
- it('renders the story name', () => {
- const card = renderStoryCard(makeStory(), doc);
- function findText(el, text) {
- if (el.textContent === text) return true;
- return (el.children || []).some(c => findText(c, text));
- }
- assert.ok(findText(card, 'Add login page'), 'card should contain story name');
- });
-
- it('has story-card class', () => {
- const card = renderStoryCard(makeStory(), doc);
- assert.ok(card.className.split(' ').includes('story-card'), 'root element should have story-card class');
- });
-
- it('status badge has data-status matching story status', () => {
- const card = renderStoryCard(makeStory({ status: 'IN_PROGRESS' }), doc);
- const badge = card.querySelector('.story-status-badge');
- assert.ok(badge, 'badge element should exist');
- assert.equal(badge.dataset.status, 'IN_PROGRESS');
- });
-
- it('status badge shows human-readable label', () => {
- const card = renderStoryCard(makeStory({ status: 'REVIEW_READY' }), doc);
- const badge = card.querySelector('.story-status-badge');
- assert.equal(badge.textContent, 'Review Ready');
- });
-
- it('shows project_id', () => {
- const card = renderStoryCard(makeStory({ project_id: 'nav' }), doc);
- function findText(el, text) {
- if (el.textContent === text) return true;
- return (el.children || []).some(c => findText(c, text));
- }
- assert.ok(findText(card, 'nav'), 'card should show project_id');
- });
-
- it('shows branch_name when present', () => {
- const card = renderStoryCard(makeStory({ branch_name: 'story/my-feature' }), doc);
- function findText(el, text) {
- if (el.textContent && el.textContent.includes(text)) return true;
- return (el.children || []).some(c => findText(c, text));
- }
- assert.ok(findText(card, 'story/my-feature'), 'card should show branch_name');
- });
-
- it('does not show branch section when branch_name is empty', () => {
- const card = renderStoryCard(makeStory({ branch_name: '' }), doc);
- const branchEl = card.querySelector('.story-branch');
- assert.ok(!branchEl, 'no .story-branch element when branch is empty');
- });
-
- it('card dataset.storyId is set to story id', () => {
- const card = renderStoryCard(makeStory({ id: 'abc-123' }), doc);
- assert.equal(card.dataset.storyId, 'abc-123');
- });
-});