import { describe, it, expect } from 'vitest'; import { sanitizeField, deriveClass, deriveLabel, makeTimelineEvent, appendEvent, } from '../../src/session/timeline.js'; import type { TimelineEvent } from '../../src/types.js'; /** * N-timeline tests — src/session/timeline.ts * * Covers: * - sanitizeField: control-char strip + truncation (SEC-H6) * - deriveClass: hook name → semantic TimelineClass (§3.1) * - deriveLabel: human-language label per event type * - makeTimelineEvent: whitelist gate + sanitize + compose * - appendEvent: immutability + bounded eviction (SEC-M8) */ // ── helpers ────────────────────────────────────────────────────────────────── function makeEvent(overrides: Partial = {}): TimelineEvent { return { at: 1000, class: 'tool', label: 'ran Bash', ...overrides, }; } // ── sanitizeField ───────────────────────────────────────────────────────────── describe('sanitizeField', () => { it('returns the string unchanged when it has no control chars and is under max', () => { expect(sanitizeField('Bash')).toBe('Bash'); }); it('strips all control characters (0x00–0x1f)', () => { // NUL, LF, CR, ESC, tab should all be removed const input = '\x00Bash\x09script\x0a\x0d\x1b[1m'; expect(sanitizeField(input)).toBe('Bashscript[1m'); }); it('strips null bytes embedded in the string', () => { expect(sanitizeField('he\x00llo')).toBe('hello'); }); it('truncates to the default max of 200 characters', () => { const long = 'a'.repeat(300); const result = sanitizeField(long); expect(result.length).toBe(200); }); it('truncates to a custom max', () => { const result = sanitizeField('abcdef', 3); expect(result).toBe('abc'); }); it('handles an empty string', () => { expect(sanitizeField('')).toBe(''); }); it('does not truncate strings exactly at the max', () => { const exact = 'x'.repeat(200); expect(sanitizeField(exact).length).toBe(200); }); it('strips control chars before truncating (strip-then-truncate)', () => { // 10 control chars + 5 regular chars; after strip only 5 remain → under max const withControls = '\x01'.repeat(10) + 'hello'; expect(sanitizeField(withControls, 8)).toBe('hello'); }); }); // ── deriveClass ─────────────────────────────────────────────────────────────── describe('deriveClass', () => { it('maps PreToolUse → "tool"', () => { expect(deriveClass('PreToolUse')).toBe('tool'); }); it('maps PostToolUse → "tool"', () => { expect(deriveClass('PostToolUse')).toBe('tool'); }); it('maps PermissionRequest → "waiting"', () => { expect(deriveClass('PermissionRequest')).toBe('waiting'); }); it('maps Notification → "waiting"', () => { expect(deriveClass('Notification')).toBe('waiting'); }); it('maps Stop → "done"', () => { expect(deriveClass('Stop')).toBe('done'); }); it('maps SessionEnd → "done"', () => { expect(deriveClass('SessionEnd')).toBe('done'); }); it('maps UserPromptSubmit → "user"', () => { expect(deriveClass('UserPromptSubmit')).toBe('user'); }); it('returns "tool" as a safe fallback for unknown event names', () => { expect(deriveClass('SomeUnknownEvent')).toBe('tool'); }); }); // ── deriveLabel ─────────────────────────────────────────────────────────────── describe('deriveLabel', () => { describe('PreToolUse', () => { it('returns a label that includes the tool name when provided', () => { const label = deriveLabel('PreToolUse', 'Bash'); expect(label).toContain('Bash'); }); it('returns a generic label when toolName is absent', () => { const label = deriveLabel('PreToolUse', undefined); expect(label.length).toBeGreaterThan(0); }); }); describe('PostToolUse', () => { it('returns a "ran" label for execution tools (Bash)', () => { const label = deriveLabel('PostToolUse', 'Bash'); expect(label.toLowerCase()).toContain('bash'); }); it('returns an "edit" flavour label for Edit tool', () => { const label = deriveLabel('PostToolUse', 'Edit'); // Should be something like "edited file" or "ran Edit" — contract just says it's human expect(label.length).toBeGreaterThan(0); }); it('returns a generic label when toolName is absent', () => { const label = deriveLabel('PostToolUse', undefined); expect(label.length).toBeGreaterThan(0); }); }); describe('waiting events', () => { it('returns an approval-flavoured label for PermissionRequest', () => { const label = deriveLabel('PermissionRequest', undefined); expect(label.length).toBeGreaterThan(0); expect(label.toLowerCase()).toMatch(/wait|approv|permiss/); }); it('returns an approval-flavoured label for Notification', () => { const label = deriveLabel('Notification', undefined); expect(label.toLowerCase()).toMatch(/wait|approv|permiss/); }); }); describe('done events', () => { it('returns a "done" label for Stop', () => { expect(deriveLabel('Stop', undefined)).toMatch(/done|stop|end/i); }); it('returns a "done" label for SessionEnd', () => { expect(deriveLabel('SessionEnd', undefined)).toMatch(/done|stop|end/i); }); }); describe('user input', () => { it('returns a user-input label for UserPromptSubmit', () => { const label = deriveLabel('UserPromptSubmit', undefined); expect(label.toLowerCase()).toMatch(/user|input|prompt/); }); }); }); // ── makeTimelineEvent ───────────────────────────────────────────────────────── describe('makeTimelineEvent', () => { it('returns null for an unknown event name (whitelist guard)', () => { expect(makeTimelineEvent('UnknownHookType', undefined, 1000)).toBeNull(); }); it('returns null for an empty string event name', () => { expect(makeTimelineEvent('', undefined, 1000)).toBeNull(); }); it('returns a TimelineEvent for PreToolUse', () => { const ev = makeTimelineEvent('PreToolUse', 'Bash', 1234); expect(ev).not.toBeNull(); expect(ev!.at).toBe(1234); expect(ev!.class).toBe('tool'); expect(ev!.toolName).toBe('Bash'); expect(ev!.label.length).toBeGreaterThan(0); }); it('returns a TimelineEvent for PostToolUse', () => { const ev = makeTimelineEvent('PostToolUse', 'Bash', 9999); expect(ev).not.toBeNull(); expect(ev!.class).toBe('tool'); expect(ev!.toolName).toBe('Bash'); }); it('returns a TimelineEvent for PermissionRequest', () => { const ev = makeTimelineEvent('PermissionRequest', undefined, 5000); expect(ev).not.toBeNull(); expect(ev!.class).toBe('waiting'); expect(ev!.toolName).toBeUndefined(); }); it('returns a TimelineEvent for Stop', () => { const ev = makeTimelineEvent('Stop', undefined, 7000); expect(ev).not.toBeNull(); expect(ev!.class).toBe('done'); }); it('returns a TimelineEvent for SessionEnd', () => { const ev = makeTimelineEvent('SessionEnd', undefined, 8000); expect(ev).not.toBeNull(); expect(ev!.class).toBe('done'); }); it('returns a TimelineEvent for UserPromptSubmit', () => { const ev = makeTimelineEvent('UserPromptSubmit', undefined, 6000); expect(ev).not.toBeNull(); expect(ev!.class).toBe('user'); }); it('returns a TimelineEvent for Notification', () => { const ev = makeTimelineEvent('Notification', undefined, 2000); expect(ev).not.toBeNull(); expect(ev!.class).toBe('waiting'); }); it('sanitizes control characters in toolName (SEC-H6)', () => { const ev = makeTimelineEvent('PostToolUse', 'Ba\x01sh\x1bscript', 1000); expect(ev).not.toBeNull(); expect(ev!.toolName).toBe('Bashscript'); }); it('truncates toolName to 200 characters (SEC-H6)', () => { const longName = 'a'.repeat(250); const ev = makeTimelineEvent('PostToolUse', longName, 1000); expect(ev).not.toBeNull(); expect(ev!.toolName!.length).toBe(200); }); it('omits toolName from the result when toolName is undefined', () => { const ev = makeTimelineEvent('Stop', undefined, 1000); expect(ev).not.toBeNull(); expect(ev!.toolName).toBeUndefined(); }); }); // ── appendEvent ─────────────────────────────────────────────────────────────── describe('appendEvent', () => { it('appends to an empty array', () => { const ev = makeEvent({ at: 100 }); const result = appendEvent([], ev, 10); expect(result).toHaveLength(1); expect(result[0]).toBe(ev); }); it('appends to a non-empty array', () => { const ev1 = makeEvent({ at: 100 }); const ev2 = makeEvent({ at: 200 }); const result = appendEvent([ev1], ev2, 10); expect(result).toHaveLength(2); expect(result[1]).toBe(ev2); }); it('does not mutate the input array', () => { const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })]; const ev = makeEvent({ at: 2 }); appendEvent(original, ev, 10); expect(original).toHaveLength(1); }); it('returns a new array reference (immutability)', () => { const original: readonly TimelineEvent[] = [makeEvent({ at: 1 })]; const ev = makeEvent({ at: 2 }); const result = appendEvent(original, ev, 10); expect(result).not.toBe(original); }); it('evicts the oldest events when length exceeds maxLen', () => { const events: readonly TimelineEvent[] = [ makeEvent({ at: 1 }), makeEvent({ at: 2 }), makeEvent({ at: 3 }), ]; const ev = makeEvent({ at: 4 }); // maxLen = 3, so appending a 4th must evict the oldest (at=1) const result = appendEvent(events, ev, 3); expect(result).toHaveLength(3); expect(result[0]!.at).toBe(2); expect(result[2]!.at).toBe(4); }); it('enforces the ring cap: append MAX+10 events → length ≤ MAX (SEC-M8)', () => { const MAX = 5; let events: readonly TimelineEvent[] = []; for (let i = 0; i < MAX + 10; i++) { events = appendEvent(events, makeEvent({ at: i }), MAX); } expect(events.length).toBeLessThanOrEqual(MAX); }); it('keeps only the newest events when trimming (oldest are evicted first)', () => { const MAX = 3; let events: readonly TimelineEvent[] = []; for (let i = 0; i < 10; i++) { events = appendEvent(events, makeEvent({ at: i }), MAX); } // Should have the last 3 events: at = 7, 8, 9 expect(events.map((e) => e.at)).toEqual([7, 8, 9]); }); it('handles maxLen of 1 (only the newest event survives)', () => { const ev1 = makeEvent({ at: 1 }); const ev2 = makeEvent({ at: 2 }); const result = appendEvent([ev1], ev2, 1); expect(result).toHaveLength(1); expect(result[0]!.at).toBe(2); }); }); // ── integration: each TimelineClass has at least one example ───────────────── describe('TimelineClass coverage (AC-A4.4)', () => { const WHITELISTED_EVENTS: Array<{ event: string; toolName?: string; expectedClass: string }> = [ { event: 'PreToolUse', toolName: 'Bash', expectedClass: 'tool' }, { event: 'PostToolUse', toolName: 'Read', expectedClass: 'tool' }, { event: 'PermissionRequest', expectedClass: 'waiting' }, { event: 'Notification', expectedClass: 'waiting' }, { event: 'Stop', expectedClass: 'done' }, { event: 'SessionEnd', expectedClass: 'done' }, { event: 'UserPromptSubmit', expectedClass: 'user' }, ]; for (const { event, toolName, expectedClass } of WHITELISTED_EVENTS) { it(`${event} → class '${expectedClass}'`, () => { const ev = makeTimelineEvent(event, toolName, Date.now()); expect(ev).not.toBeNull(); expect(ev!.class).toBe(expectedClass); }); } });