// @vitest-environment jsdom /** * test/timeline.test.ts — N-timeline-ui: activity timeline panel * * Covers: normalizeTimelineEvent (defensive), timelineIcon (mapping), * renderTimelineEvent (textContent/XSS), fetchTimeline (error→[]), * mountTimeline (polling start/stop, dispose clears interval, empty state, * newest-first, maxEvents cap). * * Security: SEC-H6 (label/toolName rendered via textContent, not innerHTML). */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import type { TimelineEvent, TimelineClass } from '../src/types.js' // Stub @xterm/xterm (pulled in transitively via preview-grid.ts import). vi.mock('@xterm/xterm', () => ({ Terminal: class FakeTerm { cols = 80 rows = 24 open = vi.fn() reset = vi.fn() resize = vi.fn() dispose = vi.fn() write = vi.fn((_d: string, cb?: () => void) => cb?.()) }, })) const { normalizeTimelineEvent, timelineIcon, renderTimelineEvent, fetchTimeline, mountTimeline, } = await import('../public/timeline.js') // ─── helpers ──────────────────────────────────────────────────────────────── function makeEvent(over: Partial = {}): TimelineEvent { return { at: Date.now(), class: 'tool', label: 'ran Bash', ...over, } } function makeRaw(over: Record = {}): unknown { return { at: Date.now(), class: 'tool', label: 'ran Bash', ...over, } } // ─── normalizeTimelineEvent ────────────────────────────────────────────────── describe('normalizeTimelineEvent', () => { it('returns null for null input', () => { expect(normalizeTimelineEvent(null)).toBeNull() }) it('returns null for undefined input', () => { expect(normalizeTimelineEvent(undefined)).toBeNull() }) it('returns null for non-object (string)', () => { expect(normalizeTimelineEvent('hello')).toBeNull() }) it('returns null for non-object (number)', () => { expect(normalizeTimelineEvent(42)).toBeNull() }) it('returns null for array input', () => { expect(normalizeTimelineEvent([1, 2, 3])).toBeNull() }) it('returns null when `at` is missing', () => { expect(normalizeTimelineEvent({ class: 'tool', label: 'hi' })).toBeNull() }) it('returns null when `at` is a string', () => { expect(normalizeTimelineEvent(makeRaw({ at: '2024-01-01' }))).toBeNull() }) it('returns null when `at` is NaN', () => { expect(normalizeTimelineEvent(makeRaw({ at: NaN }))).toBeNull() }) it('returns null when `at` is Infinity', () => { expect(normalizeTimelineEvent(makeRaw({ at: Infinity }))).toBeNull() }) it('returns null when `label` is missing', () => { expect(normalizeTimelineEvent({ at: Date.now(), class: 'tool' })).toBeNull() }) it('returns null when `label` is a number', () => { expect(normalizeTimelineEvent(makeRaw({ label: 42 }))).toBeNull() }) it('returns null when `class` is missing', () => { expect(normalizeTimelineEvent({ at: Date.now(), label: 'hi' })).toBeNull() }) it('returns null when `class` is an unknown string', () => { expect(normalizeTimelineEvent(makeRaw({ class: 'unknown-garbage' }))).toBeNull() }) it('returns null when `class` is not a string', () => { expect(normalizeTimelineEvent(makeRaw({ class: 99 }))).toBeNull() }) it('accepts all valid TimelineClass values', () => { const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user'] for (const cls of classes) { const result = normalizeTimelineEvent(makeRaw({ class: cls })) expect(result).not.toBeNull() expect(result?.class).toBe(cls) } }) it('returns a valid TimelineEvent for a well-formed input', () => { const now = Date.now() const result = normalizeTimelineEvent({ at: now, class: 'done', label: 'session ended' }) expect(result).toEqual({ at: now, class: 'done', label: 'session ended' }) }) it('includes toolName when it is a string', () => { const result = normalizeTimelineEvent(makeRaw({ toolName: 'Bash' })) expect(result?.toolName).toBe('Bash') }) it('omits toolName when it is not a string', () => { const result = normalizeTimelineEvent(makeRaw({ toolName: 123 })) expect(result?.toolName).toBeUndefined() }) it('truncates label at 500 characters', () => { const longLabel = 'x'.repeat(600) const result = normalizeTimelineEvent(makeRaw({ label: longLabel })) expect(result?.label.length).toBeLessThanOrEqual(500) }) it('truncates toolName at 200 characters', () => { const longName = 'y'.repeat(300) const result = normalizeTimelineEvent(makeRaw({ toolName: longName })) expect(result?.toolName?.length).toBeLessThanOrEqual(200) }) it('does not throw on arbitrary garbage inputs', () => { const garbage = [ {}, [], null, undefined, false, 0, '', { at: null, class: {}, label: [] }, { at: 1, class: 'tool', label: 'ok', toolName: {} }, ] for (const g of garbage) { expect(() => normalizeTimelineEvent(g)).not.toThrow() } }) }) // ─── timelineIcon ──────────────────────────────────────────────────────────── describe('timelineIcon', () => { it('returns a non-empty string for every TimelineClass', () => { const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user'] for (const cls of classes) { const icon = timelineIcon(cls) expect(typeof icon).toBe('string') expect(icon.length).toBeGreaterThan(0) } }) it('maps tool to a tool-related icon', () => { expect(timelineIcon('tool')).toBeTruthy() }) it('maps waiting to a waiting icon', () => { expect(timelineIcon('waiting')).toBeTruthy() }) it('maps done to a done/check icon', () => { const icon = timelineIcon('done') expect(typeof icon).toBe('string') }) it('maps stuck to a warning icon containing ⚠', () => { const icon = timelineIcon('stuck') expect(icon).toContain('⚠') }) it('maps user to a user icon', () => { expect(timelineIcon('user')).toBeTruthy() }) it('returns distinct icons for each class', () => { const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user'] const icons = classes.map(timelineIcon) const unique = new Set(icons) expect(unique.size).toBe(classes.length) }) }) // ─── renderTimelineEvent ───────────────────────────────────────────────────── describe('renderTimelineEvent', () => { it('returns an HTMLElement', () => { const el = renderTimelineEvent(makeEvent()) expect(el).toBeInstanceOf(HTMLElement) }) it('does NOT use innerHTML (textContent only — SEC-H6)', () => { const ev = makeEvent({ label: '' }) const row = renderTimelineEvent(ev) // innerHTML of the row should not produce script elements const scripts = row.querySelectorAll('script') expect(scripts.length).toBe(0) // The raw string should appear as text somewhere in the tree expect(row.textContent).toContain('') }) it('renders the label text as textContent', () => { const ev = makeEvent({ label: 'edited 3 files' }) const row = renderTimelineEvent(ev) expect(row.textContent).toContain('edited 3 files') }) it('renders the icon for the event class', () => { const ev = makeEvent({ class: 'stuck' }) const row = renderTimelineEvent(ev) expect(row.textContent).toContain('⚠') }) it('renders a time portion in the row', () => { const ev = makeEvent({ at: new Date('2024-06-15T14:32:00').getTime() }) const row = renderTimelineEvent(ev) // Should contain digits forming a time pattern (HH:MM) expect(row.textContent).toMatch(/\d{1,2}:\d{2}/) }) it('renders HTML-special characters in label as plain text', () => { const ev = makeEvent({ label: 'bold & "quotes"' }) const row = renderTimelineEvent(ev) expect(row.querySelectorAll('b').length).toBe(0) expect(row.textContent).toContain('bold') }) it('renders ANSI escape sequences in label as plain text', () => { const ev = makeEvent({ label: '\x1b[31mred\x1b[0m text' }) const row = renderTimelineEvent(ev) expect(row.textContent).toContain('\x1b[31mred\x1b[0m text') }) it('renders all five classes without throwing', () => { const classes: TimelineClass[] = ['tool', 'waiting', 'done', 'stuck', 'user'] for (const cls of classes) { expect(() => renderTimelineEvent(makeEvent({ class: cls }))).not.toThrow() } }) }) // ─── fetchTimeline ─────────────────────────────────────────────────────────── describe('fetchTimeline', () => { beforeEach(() => { vi.restoreAllMocks() }) it('returns [] on network error', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('net fail'))) const result = await fetchTimeline('sess-1') expect(result).toEqual([]) }) it('returns [] when response is not ok', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: false, json: async () => [] }), ) const result = await fetchTimeline('sess-1') expect(result).toEqual([]) }) it('returns [] when response JSON is not an array', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ events: [] }) }), ) const result = await fetchTimeline('sess-1') expect(result).toEqual([]) }) it('returns [] when json() rejects', async () => { vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => { throw new Error('bad json') } }), ) const result = await fetchTimeline('sess-1') expect(result).toEqual([]) }) it('fetches from the correct URL', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) vi.stubGlobal('fetch', mockFetch) await fetchTimeline('my-session-id') expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('my-session-id')) expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/events')) }) it('normalizes and returns valid events', async () => { const now = Date.now() const rawEvents = [ { at: now, class: 'tool', label: 'ran Bash', toolName: 'Bash' }, { at: now, class: 'done', label: 'session ended' }, ] vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }), ) const result = await fetchTimeline('sess-2') expect(result.length).toBe(2) expect(result[0]?.class).toBe('tool') expect(result[1]?.class).toBe('done') }) it('filters out invalid events from the response array', async () => { const now = Date.now() const rawEvents = [ { at: now, class: 'tool', label: 'ok' }, { at: 'bad', class: 'tool', label: 'ok' }, // invalid at null, { at: now, class: 'invalid-class', label: 'ok' }, // invalid class ] vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => rawEvents }), ) const result = await fetchTimeline('sess-3') expect(result.length).toBe(1) expect(result[0]?.label).toBe('ok') }) }) // ─── mountTimeline ─────────────────────────────────────────────────────────── describe('mountTimeline', () => { beforeEach(() => { vi.useFakeTimers() vi.restoreAllMocks() }) afterEach(() => { vi.useRealTimers() }) function makeContainer(): HTMLDivElement { return document.createElement('div') } /** * Flush the microtask queue (Promise chain) without advancing fake timers. * Needed to let `void poll()` → `fetchTimeline()` → `render()` complete. * Four ticks cover: poll() entry → fetch call → await response → render(). */ async function flushPromises(): Promise { await Promise.resolve() await Promise.resolve() await Promise.resolve() await Promise.resolve() } it('does an initial fetch on mount', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountTimeline(container, 'sess-1') await flushPromises() expect(mockFetch).toHaveBeenCalled() handle.dispose() }) it('shows empty state when no events are returned', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) const container = makeContainer() const handle = mountTimeline(container, 'sess-empty') await flushPromises() expect(container.textContent).toContain('No activity') handle.dispose() }) it('renders events newest-first (reverses server order)', async () => { const now = Date.now() const events = [ { at: now - 2000, class: 'tool', label: 'first event' }, { at: now - 1000, class: 'tool', label: 'second event' }, { at: now, class: 'done', label: 'done event' }, ] vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events })) const container = makeContainer() const handle = mountTimeline(container, 'sess-order') await flushPromises() const rows = container.querySelectorAll('.tl-row') // Newest first: 'done event' at index 0, 'first event' at last expect(rows[0]?.textContent).toContain('done event') expect(rows[rows.length - 1]?.textContent).toContain('first event') handle.dispose() }) it('caps rendered events at maxEvents', async () => { const now = Date.now() const events = Array.from({ length: 10 }, (_, i) => ({ at: now + i, class: 'tool', label: `event ${i}`, })) vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => events })) const container = makeContainer() const handle = mountTimeline(container, 'sess-cap', { maxEvents: 3 }) await flushPromises() const rows = container.querySelectorAll('.tl-row') expect(rows.length).toBe(3) handle.dispose() }) it('polls at the specified refreshMs interval', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountTimeline(container, 'sess-poll', { refreshMs: 1000 }) await flushPromises() const callsAfterMount = mockFetch.mock.calls.length // Advance by refreshMs → fire the interval once vi.advanceTimersByTime(1000) await flushPromises() expect(mockFetch.mock.calls.length).toBeGreaterThan(callsAfterMount) handle.dispose() }) it('dispose stops polling (no more fetches after dispose)', async () => { const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => [] }) vi.stubGlobal('fetch', mockFetch) const container = makeContainer() const handle = mountTimeline(container, 'sess-dispose', { refreshMs: 500 }) await flushPromises() handle.dispose() const callsAfterDispose = mockFetch.mock.calls.length // Advance time — interval is cleared, no more fetches vi.advanceTimersByTime(2000) await flushPromises() expect(mockFetch.mock.calls.length).toBe(callsAfterDispose) }) it('calling dispose twice does not throw', async () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) const container = makeContainer() const handle = mountTimeline(container, 'sess-double-dispose') await flushPromises() expect(() => { handle.dispose() handle.dispose() }).not.toThrow() }) it('returns a handle with a dispose function', () => { vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [] })) const container = makeContainer() const handle = mountTimeline(container, 'sess-handle') expect(typeof handle.dispose).toBe('function') handle.dispose() }) it('uses the label field rendered as textContent (SEC-H6)', async () => { const now = Date.now() vi.stubGlobal( 'fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => [{ at: now, class: 'tool', label: '' }], }), ) const container = makeContainer() const handle = mountTimeline(container, 'sess-xss') await flushPromises() expect(container.querySelectorAll('script').length).toBe(0) expect(container.textContent).toContain('') handle.dispose() }) it('clears and re-renders on each poll cycle', async () => { let callCount = 0 vi.stubGlobal( 'fetch', vi.fn().mockImplementation(async () => { callCount++ const label = `event-cycle-${callCount}` return { ok: true, json: async () => [{ at: Date.now(), class: 'tool', label }] } }), ) const container = makeContainer() const handle = mountTimeline(container, 'sess-refresh', { refreshMs: 500 }) await flushPromises() expect(container.textContent).toContain('event-cycle-1') vi.advanceTimersByTime(500) await flushPromises() // After re-render, shows latest cycle data expect(container.textContent).toContain('event-cycle-2') // Only shows one set of rows (cleared before re-render) expect(container.querySelectorAll('.tl-row').length).toBe(1) handle.dispose() }) })