gstack/browse/test/terminal-agent-detach-reatt...

128 lines
5.9 KiB
TypeScript

import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
// v1.44 Commit 3 — detach state machine + ring buffer + re-attach replay.
//
// The state machine is what turns a single network blip from "fall through
// to ENDED state, click Restart" into "silent re-attach with scrollback
// intact, keep typing." Live WS cycles + buffer-overflow exercises belong
// in the e2e tier; these static-grep tripwires defend the load-bearing
// protocol + correctness properties.
const AGENT_TS = path.resolve(new URL(import.meta.url).pathname, '..', '..', 'src', 'terminal-agent.ts');
describe('terminal-agent detach + re-attach (v1.44+ Commit 3)', () => {
test('1. PtySession carries ring buffer + alt-screen + detach state', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const i = src.indexOf('interface PtySession {');
const j = src.indexOf('\n}', i);
const block = src.slice(i, j);
expect(block).toContain('liveWs: any | null');
expect(block).toContain('ringBuffer: Buffer[]');
expect(block).toContain('ringBufferBytes: number');
expect(block).toContain('altScreenActive: boolean');
expect(block).toContain('detached: boolean');
expect(block).toContain('detachTimer:');
});
test('2. RING_BUFFER_MAX_BYTES default is 1 MB, env-overridable', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('GSTACK_PTY_RING_BUFFER_BYTES');
expect(src).toContain('1024 * 1024');
});
test('3. DETACH_WINDOW_MS default is 60s, env-overridable', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toContain('GSTACK_PTY_DETACH_WINDOW_MS');
expect(src).toContain("'60000'");
});
test('4. appendToRingBuffer evicts oldest frames past the cap', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/function appendToRingBuffer\(/);
// Eviction loop: must keep at least one frame even at extreme caps
// (otherwise a single oversized frame would empty the buffer).
expect(src).toMatch(/session\.ringBufferBytes > RING_BUFFER_MAX_BYTES/);
expect(src).toContain('session.ringBuffer.length > 1');
expect(src).toContain('session.ringBuffer.shift()');
});
test('5. alt-screen tracking watches for CSI ?1049h / CSI ?1049l', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Canonical xterm enter/exit alt-screen sequences. Must update
// session.altScreenActive so the replay prelude knows.
expect(src).toContain('\\x1b[?1049h');
expect(src).toContain('\\x1b[?1049l');
expect(src).toContain('session.altScreenActive');
});
test('6. buildReplayPayload prefixes soft-reset (+ alt-screen if active)', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
expect(src).toMatch(/function buildReplayPayload\(/);
// DECSTR soft reset — re-defaults character attributes after the
// client's RIS clears the xterm buffer.
expect(src).toContain('\\x1b[!p');
// Conditionally re-enter alt-screen if claude was in a tool-call
// (alt-screen mode) at detach.
expect(src).toContain('session.altScreenActive');
});
test('7. WS open() re-attaches when sessionId already lives in sessionsById', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, 'open(ws) {', 'message(ws, raw) {');
expect(block).toContain('sessionsById.get(sessionId)');
expect(block).toContain('existing.liveWs = ws');
expect(block).toContain('clearTimeout(existing.detachTimer)');
// Tells the client to write RIS before treating the next binary
// frame as replay.
expect(block).toContain("type: 'reattach-begin'");
expect(block).toContain('sendBinary(buildReplayPayload(existing))');
});
test('8. WS close starts detach timer for non-intentional close codes', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const i = src.indexOf('close(ws');
const j = src.indexOf('function handleTabState', i);
const block = src.slice(i, j);
// 4001 = intentional restart (Commit 2), 4404 = no-claude, 1000 = clean
// exit. Any other code (1006 abnormal, 1001 going-away, etc.) gets the
// 60s detach grace.
expect(block).toContain('code === 4001');
expect(block).toContain('code === 4404');
expect(block).toContain('code === 1000');
expect(block).toContain('session.detached = true');
expect(block).toContain('session.detachTimer = setTimeout');
expect(block).toContain('DETACH_WINDOW_MS');
// Detach timer must unref so the bun process can exit cleanly.
expect(block).toContain('detachTimer as any)?.unref?.()');
});
test('9. /internal/restart cancels detach timer before disposal', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
const block = sliceBetween(src, "url.pathname === '/internal/restart'", "// /claude-available");
// Without the cancellation, a later detach-timer fire would dispose a
// session that's already been disposed by the explicit restart path.
expect(block).toContain('clearTimeout(session.detachTimer)');
});
test('10. PTY on-data writes through session.liveWs (not the original ws closure)', () => {
const src = fs.readFileSync(AGENT_TS, 'utf-8');
// Critical for re-attach correctness: the PTY's on-data callback
// closes over `session`, not the original `ws`, so after re-attach
// it routes to the new liveWs automatically.
expect(src).toContain('session.liveWs.sendBinary');
// Always append to the ring buffer regardless of attach state — so
// a detached session still captures output for the next re-attach.
expect(src).toContain('appendToRingBuffer(session, flush)');
});
});
function sliceBetween(source: string, start: string, end: string): string {
const i = source.indexOf(start);
if (i === -1) throw new Error(`marker not found: ${start}`);
const j = source.indexOf(end, i + start.length);
if (j === -1) throw new Error(`end marker not found: ${end}`);
return source.slice(i, j);
}