Files
web-terminal/test/ring-buffer.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

194 lines
7.2 KiB
TypeScript
Raw Blame History

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');
});
});
});