Implements docs/PLAN_WALKAWAY_WORKBENCH.md (27 tasks, waves R0→W0→W1×14→W2→W3→W4)
via module-builder agents. 23 tasks built, 0 blocked.
Band A (finish the walk-away loop): A1 Web Push + lock-screen approve/deny
(web-push dep), A2 voice dictation, A3 quick-reply chips + saved-prompt palette,
A4 activity timeline, A5 stuck/idle alert.
Band B (workbench above the terminal): B1 read-only git diff viewer, B2 statusLine
telemetry → per-tab cost/context/PR gauges, B3 create git worktrees from the UI,
B4 plan-mode / permission-mode relay.
New: src/push/* (subscription store + VAPID push), src/http/{diff,statusline}.ts,
src/session/timeline.ts, public/{diff,timeline,quickreply,push-ui,...}.ts, sw-push,
statusLine script; extends hook intake, manager, server routes (Origin/CSRF guards
+ per-IP rate limits on state-changing ones; loopback-only ingest), terminal-session,
tabs, projects detail, service worker, setup-hooks (statusLine + ntfy bridge).
Orchestrator reconciled a W0 contract gap: added the 21 v0.7 Config fields to the
Config interface in types.ts (T-types had left them only in config.ts's return).
Verified: both tsc clean, full vitest + coverage 91.4/84.1/92.2/93.4 (≥80×4),
build:web OK. W4 review: no CRITICAL/HIGH; all security checks pass. Follow-ups
(non-blocking): move approve.mode validation into parseClientMessage, drop CSP
ws:/wss: wildcard, validate worktree base ref, +2 targeted tests.
527 lines
18 KiB
TypeScript
527 lines
18 KiB
TypeScript
// @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> = {}): TimelineEvent {
|
|
return {
|
|
at: Date.now(),
|
|
class: 'tool',
|
|
label: 'ran Bash',
|
|
...over,
|
|
}
|
|
}
|
|
|
|
function makeRaw(over: Record<string, unknown> = {}): 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: '<script>alert(1)</script>' })
|
|
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('<script>alert(1)</script>')
|
|
})
|
|
|
|
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: '<b>bold</b> & "quotes"' })
|
|
const row = renderTimelineEvent(ev)
|
|
expect(row.querySelectorAll('b').length).toBe(0)
|
|
expect(row.textContent).toContain('<b>bold</b>')
|
|
})
|
|
|
|
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<void> {
|
|
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: '<script>xss</script>' }],
|
|
}),
|
|
)
|
|
const container = makeContainer()
|
|
const handle = mountTimeline(container, 'sess-xss')
|
|
await flushPromises()
|
|
expect(container.querySelectorAll('script').length).toBe(0)
|
|
expect(container.textContent).toContain('<script>xss</script>')
|
|
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()
|
|
})
|
|
})
|