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.
219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
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');
|
||
});
|
||
});
|
||
});
|