summaryrefslogtreecommitdiff
path: root/web/test/event-timeline.test.mjs
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/event-timeline.test.mjs
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/event-timeline.test.mjs')
-rw-r--r--web/test/event-timeline.test.mjs112
1 files changed, 112 insertions, 0 deletions
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), []);
+ });
+});