Files
web-terminal/test/ring-buffer.test.ts
Yaojia Wang 787f806e02 feat(v0.4): session manager grid with live preview thumbnails
The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.

- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
  split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
  it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
  auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests

Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
2026-06-19 11:37:32 +02:00

219 lines
8.1 KiB
TypeScript
Raw Permalink 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('tail()', () => {
it('returns empty for an empty buffer or non-positive maxBytes', () => {
const rb = createRingBuffer(1024);
expect(rb.tail(100)).toBe('');
rb.append('hello');
expect(rb.tail(0)).toBe('');
});
it('returns the most recent chunks up to ~maxBytes, on chunk boundaries', () => {
const rb = createRingBuffer(1024);
rb.append('aaaa'); // 4 bytes
rb.append('bbbb'); // 4 bytes
rb.append('cccc'); // 4 bytes
// maxBytes=5 → newest 'cccc' (4) then 'bbbb' (8 ≥ 5) → stop; never splits.
expect(rb.tail(5)).toBe('bbbbcccc');
});
it('returns the whole buffer when maxBytes exceeds its size (no soft-reset)', () => {
const rb = createRingBuffer(1024);
rb.append('one');
rb.append('two');
expect(rb.tail(1000)).toBe('onetwo');
});
});
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');
});
});
});