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:
289
test/config.test.ts
Normal file
289
test/config.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
119
test/helpers/mock-pty.test.ts
Normal file
119
test/helpers/mock-pty.test.ts
Normal 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
123
test/helpers/mock-pty.ts
Normal 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
78
test/origin.test.ts
Normal 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
344
test/protocol.test.ts
Normal 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
193
test/ring-buffer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user