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.
This commit is contained in:
Yaojia Wang
2026-06-16 08:09:40 +02:00
parent 0e10dc21c3
commit 0fa0b69ce9
15 changed files with 1579 additions and 0 deletions

View File

157
src/config.ts Normal file
View File

@@ -0,0 +1,157 @@
/**
* src/config.ts — T4: environment config loader
*
* Reads env vars once at startup, validates them, derives allowedOrigins from
* local NIC IPs (M1 — NOT from bindHost; 0.0.0.0 is never a valid Origin),
* and returns a frozen Config object.
*/
import os from 'node:os'
import type { Config, EnvLike } from './types.js'
// ── constants ─────────────────────────────────────────────────────────────────
const DEFAULT_PORT = 3000
const DEFAULT_BIND_HOST = '0.0.0.0'
const DEFAULT_IDLE_TTL_SEC = 24 * 60 * 60 // 24 hours in seconds
const DEFAULT_SCROLLBACK_BYTES = 2 * 1024 * 1024 // 2 MB
const DEFAULT_MAX_PAYLOAD_BYTES = 1 * 1024 * 1024 // 1 MB
const DEFAULT_WS_PATH = '/term'
// ── helpers ───────────────────────────────────────────────────────────────────
function parsePositiveInt(
raw: string | undefined,
label: string,
fallback: number,
): number {
if (raw === undefined) return fallback
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: ${label}=${JSON.stringify(raw)} — must be a non-negative integer`,
)
}
return n
}
function parsePort(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_PORT
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 1 || n > 65535) {
throw new Error(
`Invalid config: PORT=${JSON.stringify(raw)} — must be an integer 165535`,
)
}
return n
}
function parseIdleTtl(raw: string | undefined): number {
if (raw === undefined) return DEFAULT_IDLE_TTL_SEC * 1000
const n = Number(raw)
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) {
throw new Error(
`Invalid config: IDLE_TTL=${JSON.stringify(raw)} — must be a non-negative integer (seconds)`,
)
}
return n * 1000
}
/**
* Derive allowedOrigins from host NIC IPs (M1).
*
* Rules:
* - Enumerate os.networkInterfaces(), pick IPv4 entries.
* - Skip internal (loopback) entries — 127.0.0.1 / localhost are added explicitly.
* - For every qualifying address, produce both http:// and https:// origins.
* - Append ALLOWED_ORIGINS env-var items (comma-separated).
* - Deduplicate.
* - Never include 0.0.0.0 (wildcard listen address, never a browser Origin).
*/
function deriveAllowedOrigins(port: number, extraEnv: string | undefined): readonly string[] {
const set = new Set<string>()
// 1. Always include localhost / loopback
const loopbackHosts = ['localhost', '127.0.0.1']
for (const host of loopbackHosts) {
set.add(`http://${host}:${port}`)
set.add(`https://${host}:${port}`)
}
// 2. Enumerate NIC IPv4 addresses — skip internal (loopback already handled above)
const ifaces = os.networkInterfaces()
for (const ifaceList of Object.values(ifaces)) {
if (!ifaceList) continue
for (const entry of ifaceList) {
// Only IPv4, non-internal entries; never 0.0.0.0
if (
entry.family === 'IPv4' &&
!entry.internal &&
entry.address !== '0.0.0.0'
) {
set.add(`http://${entry.address}:${port}`)
set.add(`https://${entry.address}:${port}`)
}
}
}
// 3. Merge ALLOWED_ORIGINS env var (comma-separated list of origin strings)
if (extraEnv) {
for (const raw of extraEnv.split(',')) {
const trimmed = raw.trim()
if (trimmed) set.add(trimmed)
}
}
return Object.freeze([...set])
}
// ── loadConfig ────────────────────────────────────────────────────────────────
/**
* Read and validate environment variables; fill in defaults for missing keys.
* Invalid values (port out of range, TTL non-integer, etc.) throw immediately
* so the server never starts in a misconfigured state (fail-fast).
*
* The returned Config is Object.freeze'd — immutable after construction.
*/
export function loadConfig(env: EnvLike): Config {
const port = parsePort(env['PORT'])
const bindHost = env['BIND_HOST'] ?? DEFAULT_BIND_HOST
const shellPath =
env['SHELL_PATH'] ?? (process.env['SHELL'] as string | undefined) ?? '/bin/zsh'
const homeDir = os.homedir()
const idleTtlMs = parseIdleTtl(env['IDLE_TTL'])
const scrollbackBytes = parsePositiveInt(
env['SCROLLBACK_BYTES'],
'SCROLLBACK_BYTES',
DEFAULT_SCROLLBACK_BYTES,
)
const maxPayloadBytes = parsePositiveInt(
env['MAX_PAYLOAD_BYTES'],
'MAX_PAYLOAD_BYTES',
DEFAULT_MAX_PAYLOAD_BYTES,
)
const wsPath = env['WS_PATH'] ?? DEFAULT_WS_PATH
const allowedOrigins = deriveAllowedOrigins(port, env['ALLOWED_ORIGINS'])
return Object.freeze({
port,
bindHost,
shellPath,
homeDir,
idleTtlMs,
scrollbackBytes,
maxPayloadBytes,
wsPath,
allowedOrigins,
} satisfies Config)
}

View File

53
src/http/origin.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* src/http/origin.ts — Origin whitelist check (T7)
*
* Pure function, no side effects. Security-critical:
* this is the CSWSH (Cross-Site WebSocket Hijacking) defence described in
* TECH_DOC §7 and ARCHITECTURE §3.3.
*
* Strategy:
* - Parse the incoming Origin as a URL.
* - Compare scheme + hostname + port against every entry in the allowed list.
* - Both host AND port must match (ARCHITECTURE §3.3: "host+port 双匹配").
* - undefined Origin (non-browser clients: curl, scripts) → reject by default.
* This is the single central policy point — no scattered `if` checks elsewhere.
*/
/**
* Returns true iff `origin` exactly matches one of the `allowed` origins.
*
* Matching is scheme + hostname + port (all three must agree).
* `undefined` → false (non-browser client; default-deny, no scattered if-chains).
*/
export function isOriginAllowed(
origin: string | undefined,
allowed: readonly string[],
): boolean {
// Central policy: undefined Origin is rejected. No other code path handles this.
if (origin === undefined || origin === '') {
return false
}
let incomingUrl: URL
try {
incomingUrl = new URL(origin)
} catch {
// Malformed Origin value → reject.
return false
}
return allowed.some((entry) => {
let allowedUrl: URL
try {
allowedUrl = new URL(entry)
} catch {
return false
}
return (
incomingUrl.protocol === allowedUrl.protocol &&
incomingUrl.hostname === allowedUrl.hostname &&
incomingUrl.port === allowedUrl.port
)
})
}

158
src/protocol.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* src/protocol.ts — WebSocket message codec + validation (T5, pure functions)
*
* Invariant #3 (ARCHITECTURE §8): parseClientMessage NEVER throws.
* All invalid input flows through ParseResult.ok=false.
*
* Imports only from src/types.ts (frozen shared contract, T2).
*/
import type { ClientMessage, ServerMessage, ParseResult } from './types.js'
// ─── SESSION_ID_RE (M7) ───────────────────────────────────────────────────────
/**
* UUID v4 pattern.
* - 8-4-4-4-12 hex groups
* - version nibble must be '4'
* - variant nibble must be 8, 9, a, or b (10xx in binary)
*
* M7: strings like "abc123" must be rejected; crypto.randomUUID() values must pass.
*/
export const SESSION_ID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
// ─── Type whitelist ───────────────────────────────────────────────────────────
const ALLOWED_TYPES = new Set<string>(['attach', 'input', 'resize'])
// ─── parseClientMessage ───────────────────────────────────────────────────────
/**
* Parse and validate a raw WS text frame as a ClientMessage.
*
* Steps (ARCHITECTURE §3.2 / TECH_DOC §5.3):
* 1. JSON.parse — bad JSON → ok=false
* 2. Must be a plain object (not null, array, etc.)
* 3. type must be in whitelist {'attach','input','resize'}
* 4. Per-type field validation:
* - resize: cols and rows are integers in [1, 1000]
* - input: data is a string (passed through verbatim, invariant #9)
* - attach: sessionId is null OR a string matching SESSION_ID_RE (M7)
*
* NEVER throws — all errors return { ok: false, error: <message> }.
*/
export function parseClientMessage(raw: string): ParseResult {
// Step 1: JSON parse
let parsed: unknown
try {
parsed = JSON.parse(raw)
} catch {
return { ok: false, error: 'invalid JSON' }
}
// Step 2: Must be a plain object
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
return { ok: false, error: 'message must be a JSON object' }
}
const obj = parsed as Record<string, unknown>
// Step 3: type whitelist
const type = obj['type']
if (typeof type !== 'string' || !ALLOWED_TYPES.has(type)) {
return { ok: false, error: `unknown or missing type: ${String(type)}` }
}
// Step 4: per-type validation
if (type === 'resize') {
return validateResize(obj)
}
if (type === 'input') {
return validateInput(obj)
}
// type === 'attach'
return validateAttach(obj)
}
// ─── Per-type validators ──────────────────────────────────────────────────────
function validateResize(obj: Record<string, unknown>): ParseResult {
const { cols, rows } = obj
if (!isValidDimension(cols)) {
return {
ok: false,
error: `resize.cols must be an integer in [1, 1000], got ${String(cols)}`,
}
}
if (!isValidDimension(rows)) {
return {
ok: false,
error: `resize.rows must be an integer in [1, 1000], got ${String(rows)}`,
}
}
return {
ok: true,
message: { type: 'resize', cols: cols as number, rows: rows as number },
}
}
function isValidDimension(value: unknown): value is number {
return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 1000
}
function validateInput(obj: Record<string, unknown>): ParseResult {
const { data } = obj
if (typeof data !== 'string') {
return { ok: false, error: `input.data must be a string, got ${typeof data}` }
}
// Invariant #9: data passed through verbatim, no content filtering
return { ok: true, message: { type: 'input', data } }
}
function validateAttach(obj: Record<string, unknown>): ParseResult {
const { sessionId } = obj
// sessionId must be explicitly present as null or a UUID v4 string (M7)
if (!Object.prototype.hasOwnProperty.call(obj, 'sessionId')) {
return { ok: false, error: 'attach.sessionId field is required' }
}
if (sessionId === null) {
return { ok: true, message: { type: 'attach', sessionId: null } }
}
if (typeof sessionId !== 'string') {
return {
ok: false,
error: `attach.sessionId must be null or a UUID v4 string, got ${typeof sessionId}`,
}
}
if (!SESSION_ID_RE.test(sessionId)) {
return {
ok: false,
error: `attach.sessionId is not a valid UUID v4: ${sessionId}`,
}
}
return { ok: true, message: { type: 'attach', sessionId } }
}
// ─── serialize ────────────────────────────────────────────────────────────────
/**
* Serialize a ServerMessage to a JSON text frame.
*
* Internal trusted data — JSON.stringify directly (ARCHITECTURE §3.2).
*/
export function serialize(msg: ServerMessage): string {
return JSON.stringify(msg)
}

View File

View File

@@ -0,0 +1,65 @@
/**
* src/session/ring-buffer.ts (T6) — output scrollback for reconnect replay.
*
* M2 (the easy-to-get-wrong part). PTY output is an opaque ANSI byte stream; we
* must NEVER cut a multi-byte UTF-8 codepoint or an ANSI escape sequence in half,
* or the first screen on reconnect renders as garbage — exactly the vibe-coding
* "refresh and the Claude TUI is still there" path. So:
*
* - capacity is measured in REAL UTF-8 bytes (Buffer.byteLength), because
* `string.length` counts UTF-16 code units, not bytes;
* - eviction happens on whole append-chunk boundaries (drop the oldest chunk),
* so no chunk is ever sliced (the trade-off is the live size floats a little
* above/below maxBytes);
* - snapshot() prepends a soft-reset `\x1b[0m` so that even if the oldest
* surviving chunk started mid-attribute, the terminal is reset first.
*
* Implements the frozen `RingBuffer` contract in src/types.ts (read-only import).
*/
import type { RingBuffer } from '../types.js';
const SOFT_RESET = '\x1b[0m';
interface Chunk {
readonly text: string;
readonly bytes: number;
}
/**
* Create a ring buffer holding at most ~`maxBytes` UTF-8 bytes of recent output.
*
* @param maxBytes capacity in real UTF-8 bytes (e.g. `cfg.scrollbackBytes`).
*/
export function createRingBuffer(maxBytes: number): RingBuffer {
// Immutable-update style: each append produces a new chunk list / total rather
// than mutating shared state in place (per coding-style immutability rule).
let chunks: readonly Chunk[] = [];
let totalBytes = 0;
function append(chunk: string): void {
if (chunk.length === 0) return; // nothing to store; keeps accounting clean
const bytes = Buffer.byteLength(chunk, 'utf8');
let nextChunks: Chunk[] = [...chunks, { text: chunk, bytes }];
let nextTotal = totalBytes + bytes;
// Evict whole oldest chunks until we fit — but never drop the only remaining
// chunk, so a lone chunk larger than capacity is kept intact (cannot split).
while (nextTotal > maxBytes && nextChunks.length > 1) {
const oldest = nextChunks[0]!;
nextChunks = nextChunks.slice(1);
nextTotal -= oldest.bytes;
}
chunks = nextChunks;
totalBytes = nextTotal;
}
function snapshot(): string {
// Soft-reset first (M2 safety net), then the current buffer verbatim.
return SOFT_RESET + chunks.map((c) => c.text).join('');
}
return { append, snapshot };
}

View File

289
test/config.test.ts Normal file
View File

@@ -0,0 +1,289 @@
/**
* test/config.test.ts — T4: config module unit tests (TDD)
*
* Strategy:
* - Mock `node:os` networkInterfaces to inject fake NICs (M1)
* - Assert defaults, env overrides, validation errors, allowedOrigins shape
*
* vi.mock is hoisted to the top of the file by vitest — the factory runs before
* any import, so config.ts will receive the mocked os module.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { NetworkInterfaceInfo } from 'node:os'
// ── mock node:os ─────────────────────────────────────────────────────────────
// vi.mock is hoisted by vitest, so this factory runs before config.ts is imported.
const mockNetworkInterfaces = vi.fn<[], ReturnType<typeof import('node:os').networkInterfaces>>()
mockNetworkInterfaces.mockReturnValue({})
const mockHomedir = vi.fn<[], string>()
mockHomedir.mockReturnValue('/home/testuser')
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
return {
...actual,
default: {
...actual.default,
homedir: () => mockHomedir(),
networkInterfaces: () => mockNetworkInterfaces(),
},
homedir: () => mockHomedir(),
networkInterfaces: () => mockNetworkInterfaces(),
}
})
// Import config AFTER mock is set up (dynamic import ensures mock applies)
const { loadConfig } = await import('../src/config.js')
// Helper: build a fake IPv4 NIC entry
function makeNic(address: string, internal: boolean): NetworkInterfaceInfo {
return {
address,
netmask: '255.255.255.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal,
cidr: `${address}/24`,
}
}
// ── Default values ────────────────────────────────────────────────────────────
describe('loadConfig — defaults', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('uses PORT 3000', () => {
const cfg = loadConfig({})
expect(cfg.port).toBe(3000)
})
it('uses BIND_HOST 0.0.0.0', () => {
const cfg = loadConfig({})
expect(cfg.bindHost).toBe('0.0.0.0')
})
it('uses IDLE_TTL 24h in ms', () => {
const cfg = loadConfig({})
expect(cfg.idleTtlMs).toBe(24 * 60 * 60 * 1000)
})
it('uses SCROLLBACK_BYTES 2MB', () => {
const cfg = loadConfig({})
expect(cfg.scrollbackBytes).toBe(2 * 1024 * 1024)
})
it('uses MAX_PAYLOAD_BYTES 1MB', () => {
const cfg = loadConfig({})
expect(cfg.maxPayloadBytes).toBe(1 * 1024 * 1024)
})
it('uses WS_PATH /term', () => {
const cfg = loadConfig({})
expect(cfg.wsPath).toBe('/term')
})
it('uses homeDir from os.homedir()', () => {
mockHomedir.mockReturnValue('/home/testuser')
const cfg = loadConfig({})
expect(cfg.homeDir).toBe('/home/testuser')
})
})
// ── Env overrides ─────────────────────────────────────────────────────────────
describe('loadConfig — env overrides', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('reads PORT from env', () => {
const cfg = loadConfig({ PORT: '8080' })
expect(cfg.port).toBe(8080)
})
it('reads BIND_HOST from env', () => {
const cfg = loadConfig({ BIND_HOST: '127.0.0.1' })
expect(cfg.bindHost).toBe('127.0.0.1')
})
it('reads SHELL_PATH from env', () => {
const cfg = loadConfig({ SHELL_PATH: '/bin/bash' })
expect(cfg.shellPath).toBe('/bin/bash')
})
it('reads IDLE_TTL (seconds) and converts to ms', () => {
const cfg = loadConfig({ IDLE_TTL: '3600' })
expect(cfg.idleTtlMs).toBe(3600 * 1000)
})
it('reads SCROLLBACK_BYTES from env', () => {
const cfg = loadConfig({ SCROLLBACK_BYTES: '1048576' })
expect(cfg.scrollbackBytes).toBe(1048576)
})
it('reads MAX_PAYLOAD_BYTES from env', () => {
const cfg = loadConfig({ MAX_PAYLOAD_BYTES: '524288' })
expect(cfg.maxPayloadBytes).toBe(524288)
})
it('reads WS_PATH from env', () => {
const cfg = loadConfig({ WS_PATH: '/ws' })
expect(cfg.wsPath).toBe('/ws')
})
})
// ── Validation errors ─────────────────────────────────────────────────────────
describe('loadConfig — invalid values throw', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
})
it('throws for PORT below 1', () => {
expect(() => loadConfig({ PORT: '0' })).toThrow()
})
it('throws for PORT above 65535', () => {
expect(() => loadConfig({ PORT: '65536' })).toThrow()
})
it('throws for PORT that is not a number', () => {
expect(() => loadConfig({ PORT: 'abc' })).toThrow()
})
it('throws for IDLE_TTL that is not a number', () => {
expect(() => loadConfig({ IDLE_TTL: 'abc' })).toThrow()
})
it('throws for IDLE_TTL that is negative', () => {
expect(() => loadConfig({ IDLE_TTL: '-1' })).toThrow()
})
it('throws for SCROLLBACK_BYTES that is not a number', () => {
expect(() => loadConfig({ SCROLLBACK_BYTES: 'nope' })).toThrow()
})
it('throws for MAX_PAYLOAD_BYTES that is not a number', () => {
expect(() => loadConfig({ MAX_PAYLOAD_BYTES: 'nope' })).toThrow()
})
})
// ── allowedOrigins — M1 core tests ──────────────────────────────────────────
describe('loadConfig — allowedOrigins (M1)', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('always includes localhost variants', () => {
const cfg = loadConfig({})
const origins = cfg.allowedOrigins
expect(origins).toContain('http://localhost:3000')
expect(origins).toContain('http://127.0.0.1:3000')
})
it('never contains 0.0.0.0 (M1: bindHost must not bleed in)', () => {
// even if BIND_HOST is 0.0.0.0 (default)
const cfg = loadConfig({ BIND_HOST: '0.0.0.0' })
const hasWildcard = cfg.allowedOrigins.some((o) => o.includes('0.0.0.0'))
expect(hasWildcard).toBe(false)
})
it('includes injected LAN NIC IPv4', () => {
mockNetworkInterfaces.mockReturnValue({
en0: [makeNic('192.168.1.100', false)],
})
const cfg = loadConfig({})
expect(cfg.allowedOrigins).toContain('http://192.168.1.100:3000')
})
it('includes https:// variant for injected NIC', () => {
mockNetworkInterfaces.mockReturnValue({
en0: [makeNic('10.0.0.5', false)],
})
const cfg = loadConfig({})
expect(cfg.allowedOrigins).toContain('https://10.0.0.5:3000')
})
it('excludes internal (loopback) NICs from LAN list (127.x added separately)', () => {
mockNetworkInterfaces.mockReturnValue({
lo0: [makeNic('127.0.0.1', true)],
})
const cfg = loadConfig({})
// 127.0.0.1 added via loopback constants; no duplicates beyond http+https
const count127 = cfg.allowedOrigins.filter((o) =>
o.includes('127.0.0.1'),
).length
// http and https = 2
expect(count127).toBeLessThanOrEqual(2)
})
it('skips IPv6 entries on NICs', () => {
mockNetworkInterfaces.mockReturnValue({
en0: [
{
address: 'fe80::1',
netmask: 'ffff:ffff:ffff:ffff::',
family: 'IPv6',
mac: '00:00:00:00:00:00',
internal: false,
cidr: 'fe80::1/64',
scopeid: 0,
} as NetworkInterfaceInfo,
makeNic('192.168.1.50', false),
],
})
const cfg = loadConfig({})
const hasIPv6 = cfg.allowedOrigins.some((o) => o.includes('fe80'))
expect(hasIPv6).toBe(false)
expect(cfg.allowedOrigins).toContain('http://192.168.1.50:3000')
})
it('merges ALLOWED_ORIGINS env var into the list', () => {
const extra = 'http://mymac.local:3000,https://mymac.local:3000'
const cfg = loadConfig({ ALLOWED_ORIGINS: extra })
expect(cfg.allowedOrigins).toContain('http://mymac.local:3000')
expect(cfg.allowedOrigins).toContain('https://mymac.local:3000')
})
it('respects custom PORT when building origins', () => {
mockNetworkInterfaces.mockReturnValue({
en0: [makeNic('172.16.0.1', false)],
})
const cfg = loadConfig({ PORT: '4000' })
expect(cfg.allowedOrigins).toContain('http://172.16.0.1:4000')
expect(cfg.allowedOrigins).toContain('https://172.16.0.1:4000')
})
it('deduplicates origins when ALLOWED_ORIGINS overlaps with derived set', () => {
const cfg = loadConfig({
ALLOWED_ORIGINS: 'http://localhost:3000,http://localhost:3000',
})
const localhostCount = cfg.allowedOrigins.filter(
(o) => o === 'http://localhost:3000',
).length
expect(localhostCount).toBe(1)
})
})
// ── Immutability ──────────────────────────────────────────────────────────────
describe('loadConfig — returned object is frozen', () => {
beforeEach(() => {
mockNetworkInterfaces.mockReturnValue({})
mockHomedir.mockReturnValue('/home/testuser')
})
it('result is Object.frozen', () => {
const cfg = loadConfig({})
expect(Object.isFrozen(cfg)).toBe(true)
})
it('allowedOrigins array is frozen', () => {
const cfg = loadConfig({})
expect(Object.isFrozen(cfg.allowedOrigins)).toBe(true)
})
})

View File

View File

@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { createMockPty } from './mock-pty'
describe('createMockPty', () => {
it('emitData triggers all onData listeners', () => {
const pty = createMockPty()
const listener1Results: string[] = []
const listener2Results: string[] = []
pty.onData((data) => {
listener1Results.push(data)
})
pty.onData((data) => {
listener2Results.push(data)
})
pty.emitData('hello')
pty.emitData(' world')
expect(listener1Results).toEqual(['hello', ' world'])
expect(listener2Results).toEqual(['hello', ' world'])
})
it('onData returns a disposable that unsubscribes', () => {
const pty = createMockPty()
const results: string[] = []
const disposable = pty.onData((data) => {
results.push(data)
})
pty.emitData('first')
disposable.dispose()
pty.emitData('second')
expect(results).toEqual(['first'])
})
it('records write calls', () => {
const pty = createMockPty()
pty.write('input 1')
pty.write('input 2')
expect(pty.writes).toEqual(['input 1', 'input 2'])
})
it('records resize calls', () => {
const pty = createMockPty()
pty.resize(100, 50)
pty.resize(120, 60)
expect(pty.resizes).toEqual([
{ cols: 100, rows: 50 },
{ cols: 120, rows: 60 },
])
})
it('emitExit triggers onExit listeners', () => {
const pty = createMockPty()
const results: Array<{ exitCode: number; signal?: number }> = []
pty.onExit((event) => {
results.push(event)
})
pty.emitExit(0)
pty.emitExit(1, 9)
expect(results).toEqual([
{ exitCode: 0 },
{ exitCode: 1, signal: 9 },
])
})
it('onExit returns a disposable that unsubscribes', () => {
const pty = createMockPty()
const results: Array<{ exitCode: number; signal?: number }> = []
const disposable = pty.onExit((event) => {
results.push(event)
})
pty.emitExit(0)
disposable.dispose()
pty.emitExit(1)
expect(results).toEqual([{ exitCode: 0 }])
})
it('kill marks killed flag', () => {
const pty = createMockPty()
expect(pty.killed).toBe(false)
pty.kill()
expect(pty.killed).toBe(true)
})
it('kill can accept an optional signal', () => {
const pty = createMockPty()
pty.kill('SIGTERM')
expect(pty.killed).toBe(true)
})
it('has IPty interface properties', () => {
const pty = createMockPty()
expect(typeof pty.pid).toBe('number')
expect(typeof pty.cols).toBe('number')
expect(typeof pty.rows).toBe('number')
expect(typeof pty.onData).toBe('function')
expect(typeof pty.onExit).toBe('function')
expect(typeof pty.write).toBe('function')
expect(typeof pty.resize).toBe('function')
expect(typeof pty.kill).toBe('function')
})
})

123
test/helpers/mock-pty.ts Normal file
View File

@@ -0,0 +1,123 @@
import { IPty, PtyEvent, IDisposable } from '../../src/types'
/**
* Mock IPty for testing.
*
* Implements the full IPty interface with manual trigger methods
* (emitData, emitExit) and recording of calls (writes, resizes, killed).
*
* All properties are mutable for testing convenience.
*/
export interface MockIPty extends IPty {
/** Recorded write() calls. */
writes: string[]
/** Recorded resize() calls. */
resizes: Array<{ cols: number; rows: number }>
/** Whether kill() was called. */
killed: boolean
/** Manually trigger onData listeners. */
emitData(data: string): void
/** Manually trigger onExit listeners. */
emitExit(exitCode: number, signal?: number): void
}
/**
* Create a mock PTY for testing.
*
* pid, cols, rows are fixed but can be overridden via options for specific tests.
*/
export function createMockPty(options?: {
pid?: number
cols?: number
rows?: number
}): MockIPty {
const pid = options?.pid ?? 12345
let cols = options?.cols ?? 80
let rows = options?.rows ?? 24
const writes: string[] = []
const resizes: Array<{ cols: number; rows: number }> = []
let killed = false
// Event listeners: store arrays of listeners for each event type
const dataListeners: Array<(data: string) => void> = []
const exitListeners: Array<(event: { exitCode: number; signal?: number }) => void> = []
const onData: PtyEvent<string> = (listener: (e: string) => void): IDisposable => {
dataListeners.push(listener)
return {
dispose() {
const index = dataListeners.indexOf(listener)
if (index !== -1) {
dataListeners.splice(index, 1)
}
},
}
}
const onExit: PtyEvent<{ exitCode: number; signal?: number }> = (
listener: (e: { exitCode: number; signal?: number }) => void,
): IDisposable => {
exitListeners.push(listener)
return {
dispose() {
const index = exitListeners.indexOf(listener)
if (index !== -1) {
exitListeners.splice(index, 1)
}
},
}
}
const write = (data: string): void => {
writes.push(data)
}
const resize = (newCols: number, newRows: number): void => {
cols = newCols
rows = newRows
resizes.push({ cols: newCols, rows: newRows })
}
const kill = (signal?: string): void => {
killed = true
}
const emitData = (data: string): void => {
for (const listener of dataListeners) {
listener(data)
}
}
const emitExit = (exitCode: number, signal?: number): void => {
const event: { exitCode: number; signal?: number } = { exitCode }
if (signal !== undefined) {
event.signal = signal
}
for (const listener of exitListeners) {
listener(event)
}
}
return {
pid,
get cols() {
return cols
},
get rows() {
return rows
},
onData,
onExit,
write,
resize,
kill,
writes,
resizes,
get killed() {
return killed
},
emitData,
emitExit,
}
}

78
test/origin.test.ts Normal file
View File

@@ -0,0 +1,78 @@
/**
* test/origin.test.ts — TDD for src/http/origin.ts (T7)
*
* Covers ARCHITECTURE §3.3 and TECH_DOC §7 (CSWSH defence):
* - whitelist hit → allow
* - wrong hostname → deny
* - wrong port → deny
* - undefined Origin (non-browser client) → deny
* - evil.com → deny
*/
import { describe, it, expect } from 'vitest'
import { isOriginAllowed } from '../src/http/origin.js'
const ALLOWED: readonly string[] = [
'http://localhost:3000',
'http://192.168.1.42:3000',
'http://my-host.local:3000',
]
describe('isOriginAllowed', () => {
// ── whitelist hits ────────────────────────────────────────────────
it('allows an exact localhost origin', () => {
expect(isOriginAllowed('http://localhost:3000', ALLOWED)).toBe(true)
})
it('allows an exact LAN IP origin', () => {
expect(isOriginAllowed('http://192.168.1.42:3000', ALLOWED)).toBe(true)
})
it('allows an exact hostname.local origin', () => {
expect(isOriginAllowed('http://my-host.local:3000', ALLOWED)).toBe(true)
})
// ── hostname mismatch ─────────────────────────────────────────────
it('rejects when the hostname does not match (evil.com)', () => {
expect(isOriginAllowed('http://evil.com:3000', ALLOWED)).toBe(false)
})
it('rejects a subdomain of an allowed hostname', () => {
expect(isOriginAllowed('http://sub.localhost:3000', ALLOWED)).toBe(false)
})
it('rejects a hostname that merely contains an allowed hostname', () => {
// "evillocalhost:3000" should NOT match "localhost:3000"
expect(isOriginAllowed('http://evillocalhost:3000', ALLOWED)).toBe(false)
})
// ── port mismatch ─────────────────────────────────────────────────
it('rejects when the port does not match (correct host, wrong port)', () => {
expect(isOriginAllowed('http://localhost:9999', ALLOWED)).toBe(false)
})
it('rejects when port is absent but allowed origin has a port', () => {
// Browser sends Origin without port only when it is the default (80/443).
// Our server runs on 3000, so no-port origins must not sneak in.
expect(isOriginAllowed('http://localhost', ALLOWED)).toBe(false)
})
// ── undefined Origin (non-browser clients: curl, scripts) ─────────
it('rejects undefined Origin by default', () => {
expect(isOriginAllowed(undefined, ALLOWED)).toBe(false)
})
// ── empty / malformed origins ─────────────────────────────────────
it('rejects an empty string origin', () => {
expect(isOriginAllowed('', ALLOWED)).toBe(false)
})
it('rejects a completely different scheme (https vs http in allowed)', () => {
// Allowed list uses http://; https:// should not silently match.
expect(isOriginAllowed('https://localhost:3000', ALLOWED)).toBe(false)
})
it('rejects when allowed list is empty', () => {
expect(isOriginAllowed('http://localhost:3000', [])).toBe(false)
})
})

344
test/protocol.test.ts Normal file
View File

@@ -0,0 +1,344 @@
/**
* 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)
}
})
})

193
test/ring-buffer.test.ts Normal file
View File

@@ -0,0 +1,193 @@
import { describe, it, expect } from 'vitest';
import { createRingBuffer } from '../src/session/ring-buffer.js';
import type { RingBuffer } from '../src/types.js';
/**
* T6 / M2 tests — ring-buffer.ts
*
* Invariants under test (ARCHITECTURE §3.4 / types.ts impl anchor):
* - capacity measured in REAL UTF-8 bytes (Buffer.byteLength), not string.length;
* - over-capacity evicts the WHOLE oldest chunk (append boundary), never splitting a
* multi-byte UTF-8 codepoint or an ANSI escape sequence mid-way;
* - snapshot() prepends a soft-reset "\x1b[0m" then the current buffer.
*/
const SOFT_RESET = '\x1b[0m';
/** snapshot() minus the soft-reset prefix = the live buffer content. */
function body(rb: RingBuffer): string {
const snap = rb.snapshot();
expect(snap.startsWith(SOFT_RESET)).toBe(true);
return snap.slice(SOFT_RESET.length);
}
describe('createRingBuffer', () => {
describe('snapshot()', () => {
it('prepends a soft-reset escape even when empty', () => {
const rb = createRingBuffer(1024);
expect(rb.snapshot()).toBe(SOFT_RESET);
});
it('prepends soft-reset then the current buffer', () => {
const rb = createRingBuffer(1024);
rb.append('hello');
expect(rb.snapshot()).toBe(SOFT_RESET + 'hello');
});
});
describe('within capacity', () => {
it('replays everything appended, in order', () => {
const rb = createRingBuffer(1024);
rb.append('a');
rb.append('b');
rb.append('c');
expect(body(rb)).toBe('abc');
});
it('keeps full content when total exactly equals capacity', () => {
const rb = createRingBuffer(6); // "abcdef" = 6 bytes
rb.append('abc');
rb.append('def');
expect(body(rb)).toBe('abcdef');
});
});
describe('over capacity — evict oldest whole chunk', () => {
it('drops the oldest chunk(s) when capacity is exceeded', () => {
const rb = createRingBuffer(6);
rb.append('aaa'); // 3 bytes
rb.append('bbb'); // 3 bytes -> total 6, fits
rb.append('ccc'); // 3 bytes -> total 9 > 6, evict oldest 'aaa'
expect(body(rb)).toBe('bbbccc');
});
it('evicts multiple oldest chunks if needed to fit a large new chunk', () => {
const rb = createRingBuffer(5);
rb.append('11'); // 2
rb.append('22'); // 2 -> total 4
rb.append('33333'); // 5 bytes; must evict '11' and '22' to fit
expect(body(rb)).toBe('33333');
});
it('evicts on chunk boundaries — never a partial chunk', () => {
const rb = createRingBuffer(4);
rb.append('xxx'); // 3
rb.append('yy'); // 2 -> total 5 > 4, evict whole 'xxx', leaving 'yy'
// 'xxx' is removed entirely, not trimmed to fit exactly 4 bytes.
expect(body(rb)).toBe('yy');
});
});
describe('multi-byte UTF-8 is never split mid-codepoint', () => {
it('does not corrupt Chinese characters on eviction', () => {
// '中' is 3 UTF-8 bytes.
const oneChar = '中';
expect(Buffer.byteLength(oneChar, 'utf8')).toBe(3);
// Capacity holds ~3 of these chars (9 bytes). Append many in separate chunks.
const rb = createRingBuffer(9);
for (let i = 0; i < 10; i++) {
rb.append('中'); // each chunk = one 3-byte codepoint
}
const out = body(rb);
// No replacement char (U+FFFD) => nothing was split into invalid UTF-8.
expect(out).not.toContain('<27>');
// Every char is a clean '中'.
expect([...out].every((c) => c === '中')).toBe(true);
// Byte budget respected.
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(9);
});
it('round-trips through Buffer without producing replacement chars', () => {
const rb = createRingBuffer(12); // 4 chars * 3 bytes
const chars = ['日', '本', '語', '中', '文', '字'];
for (const c of chars) rb.append(c);
const out = body(rb);
// Re-encode/decode: if any codepoint were split, we'd see U+FFFD.
const reDecoded = Buffer.from(out, 'utf8').toString('utf8');
expect(reDecoded).toBe(out);
expect(out).not.toContain('<27>');
});
});
describe('ANSI escape sequences are never cut mid-sequence', () => {
it('keeps escape sequences whole when each is its own chunk', () => {
const rb = createRingBuffer(20);
// Each chunk is a complete colored token.
rb.append('\x1b[1;32mA\x1b[0m'); // green A + reset
rb.append('\x1b[1;31mB\x1b[0m'); // red B + reset
rb.append('\x1b[1;34mC\x1b[0m'); // blue C + reset
const out = body(rb);
// The oldest chunk(s) may be evicted, but whichever remain are intact:
// every ESC '[' begins a complete sequence ending in 'm'.
const escIndexes = [...out.matchAll(/\x1b/g)].map((m) => m.index!);
for (const idx of escIndexes) {
const seq = out.slice(idx);
expect(seq).toMatch(/^\x1b\[[0-9;]*m/);
}
// No dangling ESC at the very end (would mean a cut sequence).
expect(out.endsWith('\x1b')).toBe(false);
});
it('does not slice a single ANSI token in half on eviction', () => {
const rb = createRingBuffer(8);
const token = '\x1b[1;32m'; // 7 bytes, a complete SGR sequence
rb.append(token);
rb.append(token); // total 14 > 8 -> evict whole first token, keep second intact
const out = body(rb);
expect(out).toBe(token); // exactly one whole token, not a fragment
expect(Buffer.byteLength(out, 'utf8')).toBeLessThanOrEqual(8);
});
});
describe('byte accounting (not string.length)', () => {
it('uses real UTF-8 byte count for capacity, not UTF-16 code units', () => {
// '😀' is 2 UTF-16 code units (string.length === 2) but 4 UTF-8 bytes.
const emoji = '😀';
expect(emoji.length).toBe(2);
expect(Buffer.byteLength(emoji, 'utf8')).toBe(4);
// Capacity 8 bytes = exactly two emoji.
const rb = createRingBuffer(8);
rb.append(emoji); // 4 bytes
rb.append(emoji); // 8 bytes total, fits
rb.append(emoji); // 12 > 8 -> evict oldest, leaving two emoji (8 bytes)
const out = body(rb);
expect(Buffer.byteLength(out, 'utf8')).toBe(8);
expect([...out].length).toBe(2);
expect(out).not.toContain('<27>');
});
it('counts multi-byte content by bytes when deciding eviction', () => {
// If accounting used string.length, '中中' (length 2) would seem to fit a
// capacity-of-3 buffer; by bytes it's 6 and must not both stay.
const rb = createRingBuffer(3);
rb.append('中'); // 3 bytes, fits exactly
rb.append('中'); // 6 > 3 -> evict the first, keep the second
const out = body(rb);
expect(out).toBe('中');
expect(Buffer.byteLength(out, 'utf8')).toBe(3);
});
});
describe('edge cases', () => {
it('ignores empty appends', () => {
const rb = createRingBuffer(8);
rb.append('');
rb.append('ab');
rb.append('');
expect(body(rb)).toBe('ab');
});
it('a single chunk larger than capacity is kept as the sole chunk (cannot split)', () => {
// Eviction is on whole-chunk boundaries; a lone oversized chunk has nothing
// older to drop, so it stays intact rather than being sliced (M2: never split).
const rb = createRingBuffer(4);
rb.append('abcdefgh'); // 8 bytes > 4
expect(body(rb)).toBe('abcdefgh');
});
});
});