/** * test/protocol.test.ts — TDD tests for src/protocol.ts (T5) * * Coverage: * - SESSION_ID_RE: accepts valid UUID v4, rejects "abc123" and other non-UUIDs * - parseClientMessage: all valid paths + every invalid branch (never throws) * - serialize: all three ServerMessage types */ import { describe, it, expect } from 'vitest' import { SESSION_ID_RE, parseClientMessage, serialize } from '../src/protocol' // ─── Helpers ────────────────────────────────────────────────────────────────── const VALID_UUID = 'f47ac10b-58cc-4372-a567-0e02b2c3d479' // ─── SESSION_ID_RE ───────────────────────────────────────────────────────────── describe('SESSION_ID_RE', () => { it('accepts a valid UUID v4', () => { expect(SESSION_ID_RE.test(VALID_UUID)).toBe(true) }) it('accepts UUID v4 with uppercase hex digits', () => { expect(SESSION_ID_RE.test('F47AC10B-58CC-4372-A567-0E02B2C3D479')).toBe(true) }) it('accepts another valid UUID v4 (variant bits 8, version 4)', () => { // 41d4 → version nibble = 4 ✓; 8716 → variant nibble = 8 ✓ expect(SESSION_ID_RE.test('550e8400-e29b-41d4-8716-446655440000')).toBe(true) }) it('rejects "abc123" (M7)', () => { expect(SESSION_ID_RE.test('abc123')).toBe(false) }) it('rejects empty string', () => { expect(SESSION_ID_RE.test('')).toBe(false) }) it('rejects UUID v1 (version bit = 1)', () => { expect(SESSION_ID_RE.test('550e8400-e29b-11d4-a716-446655440000')).toBe(false) }) it('rejects UUID v4 with wrong variant (not 8/9/a/b)', () => { // variant bits should be 10xx → 8,9,a,b; 'c' is 1100 = wrong expect(SESSION_ID_RE.test('f47ac10b-58cc-4372-c567-0e02b2c3d479')).toBe(false) }) }) // ─── parseClientMessage — valid paths ───────────────────────────────────────── describe('parseClientMessage — valid messages', () => { it('parses a valid attach message with null sessionId', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: null })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'attach', sessionId: null }) } }) it('parses a valid attach message with a UUID v4 sessionId', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'attach', sessionId: VALID_UUID }) } }) it('parses a valid input message', () => { const result = parseClientMessage(JSON.stringify({ type: 'input', data: 'ls -la\r' })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'input', data: 'ls -la\r' }) } }) it('passes input.data through verbatim (raw keyboard bytes)', () => { const raw = '\x1b[A\x03\r\t\x1b[Z' const result = parseClientMessage(JSON.stringify({ type: 'input', data: raw })) expect(result.ok).toBe(true) if (result.ok && result.message.type === 'input') { expect(result.message.data).toBe(raw) } }) it('parses a valid resize message with boundary values (1)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1, rows: 1 })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'resize', cols: 1, rows: 1 }) } }) it('parses a valid resize message with boundary values (1000)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1000, rows: 1000 })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'resize', cols: 1000, rows: 1000 }) } }) it('parses a valid resize message with typical values', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual({ type: 'resize', cols: 120, rows: 40 }) } }) it('parses approve / reject (H3, no payload)', () => { const a = parseClientMessage(JSON.stringify({ type: 'approve' })) expect(a.ok).toBe(true) if (a.ok) expect(a.message).toEqual({ type: 'approve' }) const r = parseClientMessage(JSON.stringify({ type: 'reject' })) expect(r.ok).toBe(true) if (r.ok) expect(r.message).toEqual({ type: 'reject' }) }) it('rejects blur (removed in the min→latest-writer pivot)', () => { const b = parseClientMessage(JSON.stringify({ type: 'blur' })) expect(b.ok).toBe(false) }) }) // ─── parseClientMessage — invalid / error paths ─────────────────────────────── describe('parseClientMessage — invalid input (ok=false, never throws)', () => { // Bad JSON it('returns ok=false for bad JSON', () => { const result = parseClientMessage('not json at all') expect(result.ok).toBe(false) }) it('returns ok=false for empty string', () => { const result = parseClientMessage('') expect(result.ok).toBe(false) }) it('returns ok=false for JSON null', () => { const result = parseClientMessage('null') expect(result.ok).toBe(false) }) it('returns ok=false for JSON array', () => { const result = parseClientMessage('[]') expect(result.ok).toBe(false) }) it('returns ok=false for JSON number', () => { const result = parseClientMessage('42') expect(result.ok).toBe(false) }) // Unknown type it('returns ok=false for unknown type "ping"', () => { const result = parseClientMessage(JSON.stringify({ type: 'ping' })) expect(result.ok).toBe(false) }) it('returns ok=false for missing type field', () => { const result = parseClientMessage(JSON.stringify({ data: 'hello' })) expect(result.ok).toBe(false) }) it('returns ok=false for type=number', () => { const result = parseClientMessage(JSON.stringify({ type: 42 })) expect(result.ok).toBe(false) }) // resize: cols out of range it('returns ok=false for resize cols=0 (below range)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 0, rows: 40 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize cols=1001 (above range)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 1001, rows: 40 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize rows=0 (below range)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 0 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize rows=1001 (above range)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 1001 })) expect(result.ok).toBe(false) }) // resize: non-integer cols/rows it('returns ok=false for resize cols=80.5 (non-integer)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80.5, rows: 40 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize rows=24.9 (non-integer)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80, rows: 24.9 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize cols="80" (string, not number)', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: '80', rows: 40 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize missing cols', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', rows: 40 })) expect(result.ok).toBe(false) }) it('returns ok=false for resize missing rows', () => { const result = parseClientMessage(JSON.stringify({ type: 'resize', cols: 80 })) expect(result.ok).toBe(false) }) // input.data non-string it('returns ok=false when input.data is a number', () => { const result = parseClientMessage(JSON.stringify({ type: 'input', data: 42 })) expect(result.ok).toBe(false) }) it('returns ok=false when input.data is null', () => { const result = parseClientMessage(JSON.stringify({ type: 'input', data: null })) expect(result.ok).toBe(false) }) it('returns ok=false when input.data is missing', () => { const result = parseClientMessage(JSON.stringify({ type: 'input' })) expect(result.ok).toBe(false) }) it('returns ok=false when input.data is an object', () => { const result = parseClientMessage(JSON.stringify({ type: 'input', data: {} })) expect(result.ok).toBe(false) }) // attach.sessionId: "abc123" rejected (M7) it('returns ok=false for attach.sessionId="abc123" (M7)', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 'abc123' })) expect(result.ok).toBe(false) }) it('returns ok=false for attach.sessionId="" (empty string, not null or UUID)', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: '' })) expect(result.ok).toBe(false) }) it('returns ok=false for attach.sessionId=42 (not string or null)', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: 42 })) expect(result.ok).toBe(false) }) it('returns ok=false for attach.sessionId=undefined (missing field)', () => { // JSON.stringify omits undefined keys → { type: 'attach' } — no sessionId property const result = parseClientMessage(JSON.stringify({ type: 'attach' })) expect(result.ok).toBe(false) }) // attach.sessionId: valid UUID passes (M7 positive case) it('returns ok=true for attach.sessionId = valid UUID v4 (M7 positive)', () => { const result = parseClientMessage(JSON.stringify({ type: 'attach', sessionId: VALID_UUID })) expect(result.ok).toBe(true) }) }) // ─── parseClientMessage — fuzzing: must never throw ─────────────────────────── describe('parseClientMessage — fuzzy inputs never throw', () => { const fuzzInputs: unknown[] = [ undefined, null, 0, true, {}, [], '\x00\x01\xFF', '{"type":"attach","sessionId":{"$ne":null}}', '{"type":"resize","cols":null,"rows":null}', '{"type":"input","data":["a","b"]}', '{"type":"attach","sessionId":' + 'x'.repeat(10000) + '}', // valid JSON but with extra fields '{"type":"resize","cols":80,"rows":40,"extra":"ignored"}', ] for (const input of fuzzInputs) { it(`does not throw for input: ${JSON.stringify(input)?.slice(0, 60) ?? String(input)}`, () => { expect(() => parseClientMessage(input as string)).not.toThrow() }) } }) // ─── serialize ──────────────────────────────────────────────────────────────── describe('serialize', () => { it('serializes "attached" message', () => { const msg = { type: 'attached' as const, sessionId: VALID_UUID } const json = serialize(msg) expect(JSON.parse(json)).toEqual(msg) }) it('serializes "output" message', () => { const msg = { type: 'output' as const, data: '\x1b[1;32mHello\x1b[0m' } const json = serialize(msg) expect(JSON.parse(json)).toEqual(msg) }) it('serializes "exit" message with code only', () => { const msg = { type: 'exit' as const, code: 0 } const json = serialize(msg) expect(JSON.parse(json)).toEqual(msg) }) it('serializes "exit" message with code and reason', () => { const msg = { type: 'exit' as const, code: -1, reason: 'spawn failed: /bin/badshell' } const json = serialize(msg) expect(JSON.parse(json)).toEqual(msg) }) it('serialize output is a string', () => { expect(typeof serialize({ type: 'output', data: 'x' })).toBe('string') }) it('round-trip: parseClientMessage(JSON) produces the right attach message', () => { const original: { type: 'attach'; sessionId: string | null } = { type: 'attach', sessionId: VALID_UUID, } const result = parseClientMessage(JSON.stringify(original)) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual(original) } }) it('round-trip: parseClientMessage(JSON) produces the right input message', () => { const original: { type: 'input'; data: string } = { type: 'input', data: 'echo hi\r' } const result = parseClientMessage(JSON.stringify(original)) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual(original) } }) it('round-trip: parseClientMessage(JSON) produces the right resize message', () => { const original: { type: 'resize'; cols: number; rows: number } = { type: 'resize', cols: 80, rows: 24, } const result = parseClientMessage(JSON.stringify(original)) expect(result.ok).toBe(true) if (result.ok) { expect(result.message).toEqual(original) } }) })