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.
350 lines
12 KiB
TypeScript
350 lines
12 KiB
TypeScript
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 (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);
|
||
});
|
||
}
|
||
});
|