mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
test(terminal-agent): runtime tests for ring buffer + replay + alt-screen tracking
Companion to browse/test/terminal-agent-detach-reattach.test.ts (static-grep
tripwires) — calls appendToRingBuffer + buildReplayPayload directly to prove
behavioral correctness without spinning up a real Bun.serve listener.
* 11 runtime cases: append + byte counting, oversize eviction with
one-frame floor (the eviction loop guard that prevents an oversized
single frame from emptying the buffer), alt-screen tracking via
canonical xterm CSI ?1049h / CSI ?1049l, trailing-state-wins for
enter+exit pairs inside a single render frame, soft-reset prefix
ordering, optional alt-screen re-enter, payload length math.
* Exports appendToRingBuffer, buildReplayPayload, and the PtySession
interface from terminal-agent.ts (purely for testability — they
were module-private; the change is annotation-only).
* Lease registry sanity check: mint two sessions, verify distinct
sessionIds, both valid simultaneously. Catches future refactors
that accidentally couple lease + ring buffer.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -77,7 +77,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
console.error('[terminal-agent] unhandledRejection:', reason);
|
||||
});
|
||||
|
||||
interface PtySession {
|
||||
export interface PtySession {
|
||||
proc: any | null; // Bun.Subprocess once spawned
|
||||
cols: number;
|
||||
rows: number;
|
||||
@@ -185,7 +185,7 @@ const DETACH_WINDOW_MS = parseInt(
|
||||
* sequences (CSI ?1049h / CSI ?1049l) and updates session.altScreenActive
|
||||
* so the re-attach prelude knows whether to re-enter alt-screen.
|
||||
*/
|
||||
function appendToRingBuffer(session: PtySession, frame: Buffer): void {
|
||||
export function appendToRingBuffer(session: PtySession, frame: Buffer): void {
|
||||
session.ringBuffer.push(frame);
|
||||
session.ringBufferBytes += frame.length;
|
||||
while (session.ringBufferBytes > RING_BUFFER_MAX_BYTES && session.ringBuffer.length > 1) {
|
||||
@@ -219,7 +219,7 @@ function appendToRingBuffer(session: PtySession, frame: Buffer): void {
|
||||
* is what lets us prepend reset codes without clobbering the live stream
|
||||
* that resumes immediately after.
|
||||
*/
|
||||
function buildReplayPayload(session: PtySession): Buffer {
|
||||
export function buildReplayPayload(session: PtySession): Buffer {
|
||||
const parts: Buffer[] = [];
|
||||
parts.push(Buffer.from('\x1b[!p'));
|
||||
if (session.altScreenActive) parts.push(Buffer.from('\x1b[?1049h'));
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test';
|
||||
import {
|
||||
appendToRingBuffer,
|
||||
buildReplayPayload,
|
||||
type PtySession,
|
||||
} from '../src/terminal-agent';
|
||||
|
||||
// Runtime exercises for the v1.44 Commit 3 ring buffer + replay prelude.
|
||||
// Companion to browse/test/terminal-agent-detach-reattach.test.ts which
|
||||
// covers the structural invariants; this file calls the helpers directly
|
||||
// to prove behavioral correctness without spinning up a real Bun.serve
|
||||
// listener.
|
||||
|
||||
function fresh(): PtySession {
|
||||
return {
|
||||
proc: null,
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cookie: 'test-cookie',
|
||||
liveWs: null,
|
||||
sessionId: 'test-session',
|
||||
spawned: false,
|
||||
pingInterval: null,
|
||||
ringBuffer: [],
|
||||
ringBufferBytes: 0,
|
||||
altScreenActive: false,
|
||||
detached: false,
|
||||
detachTimer: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('appendToRingBuffer runtime', () => {
|
||||
test('appends frames in order and tracks byte count', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('hello '));
|
||||
appendToRingBuffer(s, Buffer.from('world'));
|
||||
expect(s.ringBuffer).toHaveLength(2);
|
||||
expect(s.ringBufferBytes).toBe(11);
|
||||
expect(Buffer.concat(s.ringBuffer).toString()).toBe('hello world');
|
||||
});
|
||||
|
||||
test('evicts oldest frames when cap exceeded', () => {
|
||||
// Default cap is 1 MB. Override via env wouldn't help inside this
|
||||
// running process (constant was read at module load), so use frames
|
||||
// big enough to exceed it deterministically.
|
||||
const s = fresh();
|
||||
const big = Buffer.alloc(400_000, 0x41); // 400 KB of 'A'
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big);
|
||||
appendToRingBuffer(s, big); // total 1.2 MB — exceeds default cap
|
||||
// Eviction must drop frames until under cap; first 400 KB chunk goes.
|
||||
expect(s.ringBuffer.length).toBeLessThan(3);
|
||||
expect(s.ringBufferBytes).toBeLessThanOrEqual(1024 * 1024);
|
||||
});
|
||||
|
||||
test('keeps at least one frame even when a single frame exceeds the cap', () => {
|
||||
const s = fresh();
|
||||
// 2 MB single frame — bigger than the 1 MB cap. The eviction loop
|
||||
// guards on `ringBuffer.length > 1`, so the single oversized frame
|
||||
// stays. Without that guard, the buffer would empty itself, defeating
|
||||
// the whole point of replay on re-attach.
|
||||
const huge = Buffer.alloc(2 * 1024 * 1024, 0x42);
|
||||
appendToRingBuffer(s, huge);
|
||||
expect(s.ringBuffer.length).toBe(1);
|
||||
expect(s.ringBufferBytes).toBe(huge.length);
|
||||
});
|
||||
|
||||
test('tracks alt-screen enter (CSI ?1049h)', () => {
|
||||
const s = fresh();
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('plain text'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
});
|
||||
|
||||
test('tracks alt-screen exit (CSI ?1049l)', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h'));
|
||||
expect(s.altScreenActive).toBe(true);
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049l'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
});
|
||||
|
||||
test('trailing state wins when enter + exit appear in one frame', () => {
|
||||
const s = fresh();
|
||||
// Tool call opened alt-screen then closed it inside one render — net
|
||||
// state is back to main screen. lastIndexOf comparison handles this.
|
||||
appendToRingBuffer(s, Buffer.from('start\x1b[?1049hmiddle\x1b[?1049lend'));
|
||||
expect(s.altScreenActive).toBe(false);
|
||||
|
||||
const s2 = fresh();
|
||||
// Reverse order: exited then re-entered — net state alt-screen.
|
||||
appendToRingBuffer(s2, Buffer.from('\x1b[?1049l\x1b[?1049h'));
|
||||
expect(s2.altScreenActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildReplayPayload runtime', () => {
|
||||
test('prepends DECSTR soft reset before ring buffer contents', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('prompt> '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
expect(payload.startsWith('\x1b[!p')).toBe(true);
|
||||
expect(payload.endsWith('prompt> ')).toBe(true);
|
||||
});
|
||||
|
||||
test('re-enters alt-screen when session was in alt-screen at detach', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('\x1b[?1049h tool output '));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Order: soft reset, alt-screen re-enter, ring buffer.
|
||||
expect(payload.indexOf('\x1b[!p')).toBeLessThan(payload.indexOf('\x1b[?1049h'));
|
||||
expect(payload.indexOf('\x1b[?1049h')).toBeLessThan(payload.indexOf('tool output'));
|
||||
});
|
||||
|
||||
test('omits alt-screen re-enter when session was on main screen', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('regular prompt'));
|
||||
const payload = buildReplayPayload(s).toString('latin1');
|
||||
// Soft reset is present, but alt-screen enter is NOT. Both substrings
|
||||
// are otherwise identical 8 bytes apart in the alphabet, so equal-
|
||||
// substring checks need to be strict.
|
||||
expect(payload).toContain('\x1b[!p');
|
||||
expect(payload).not.toContain('\x1b[?1049h');
|
||||
});
|
||||
|
||||
test('replay buffer length = soft-reset + (optional alt-screen) + ring bytes', () => {
|
||||
const s = fresh();
|
||||
appendToRingBuffer(s, Buffer.from('abc'));
|
||||
appendToRingBuffer(s, Buffer.from('def'));
|
||||
const payload = buildReplayPayload(s);
|
||||
// 4 bytes (DECSTR) + 6 bytes (abc/def) = 10 bytes. No alt-screen.
|
||||
expect(payload.length).toBe(4 + 6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lease lifecycle interplay (via pty-session-lease)', () => {
|
||||
// Cross-module behavior: lease + ring buffer are both per-session.
|
||||
// This catches the case where a refactor accidentally couples them.
|
||||
test('lease registry is independent of ring buffer state', async () => {
|
||||
const { mintLease, validateLease, __resetLeases } = await import('../src/pty-session-lease');
|
||||
__resetLeases();
|
||||
const a = mintLease();
|
||||
const b = mintLease();
|
||||
expect(a.sessionId).not.toBe(b.sessionId);
|
||||
const va = validateLease(a.sessionId);
|
||||
const vb = validateLease(b.sessionId);
|
||||
expect(va.ok && vb.ok).toBe(true);
|
||||
if (va.ok && vb.ok) {
|
||||
expect(va.expiresAt).toBe(vb.expiresAt);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user