Files
web-terminal/test/protocol.test.ts
Yaojia Wang 0fa0b69ce9 feat: W1 batch — config, protocol, ring-buffer, origin, mock-pty (TDD)
Parallel module-builder subagents, disjoint file ownership, shared tree:
- T4 src/config.ts        — loadConfig+M1 origins (32 tests)
- T5 src/protocol.ts      — parse/serialize+SESSION_ID_RE+M7 (60 tests)
- T6 src/session/ring-buffer.ts — byte-accurate, no UTF-8/ANSI split+M2 (15 tests)
- T7 src/http/origin.ts   — host+port match, undefined rejected (12 tests)
- T3 test/helpers/mock-pty.ts — IPty double w/ emitData/emitExit (9 tests)

Verified by orchestrator: tsc --noEmit clean; full suite 128 tests pass.
2026-06-16 08:09:40 +02:00

345 lines
12 KiB
TypeScript

/**
* 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 })
}
})
})
// ─── 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)
}
})
})