Files
web-terminal/test/session/timeline.test.ts
Yaojia Wang d6809c65c4 feat(v0.7): Walk-away Workbench (Band A + B) — multi-agent parallel build
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.
2026-06-30 17:42:18 +02:00

350 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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> = {}): 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 (0x000x1f)', () => {
// 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);
});
}
});