vibetunnel/web/src/test/unit/stream-pruning.test.ts
Peter Steinberger 192fd7e941
Improve asciicast clear offset caching (#333)
Co-authored-by: Claude <noreply@anthropic.com>
2025-07-15 03:24:15 +02:00

221 lines
8.2 KiB
TypeScript

import type { Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SessionManager } from '../../server/pty/session-manager.js';
import type { AsciinemaHeader } from '../../server/pty/types.js';
import { StreamWatcher } from '../../server/services/stream-watcher.js';
import {
mockAsciinemaNoClears,
mockAsciinemaWithClearMidLine,
mockAsciinemaWithClears,
} from '../fixtures/test-data.js';
// Type for asciinema events used in tests
type TestAsciinemaEvent = [number | 'exit', string | number, string?];
describe('StreamWatcher - Asciinema Stream Pruning', () => {
let streamWatcher: StreamWatcher;
let tempDir: string;
let mockResponse: Partial<Response>;
let writtenData: string[] = [];
beforeEach(() => {
// Create temp directory for test files
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stream-pruning-test-'));
// Mock response object
writtenData = [];
mockResponse = {
write: vi.fn((data: string) => {
writtenData.push(data);
return true;
}),
end: vi.fn(),
locals: {},
};
const sessionManager = new SessionManager(tempDir);
streamWatcher = new StreamWatcher(sessionManager);
});
afterEach(() => {
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Helper to create test asciinema file
function createTestFile(
filename: string,
header: AsciinemaHeader,
events: TestAsciinemaEvent[]
): string {
const filepath = path.join(tempDir, filename);
const lines = [JSON.stringify(header), ...events.map((event) => JSON.stringify(event))];
fs.writeFileSync(filepath, lines.join('\n'));
return filepath;
}
// Helper to parse SSE data
function parseSSEData(data: string[]): Array<AsciinemaHeader | TestAsciinemaEvent> {
return data
.filter((line) => line.startsWith('data: '))
.map((line) => {
const jsonStr = line.substring(6).trim();
try {
return JSON.parse(jsonStr);
} catch {
return null;
}
})
.filter(Boolean);
}
it('should prune content before the last clear sequence', async () => {
const filepath = createTestFile(
'with-clears.cast',
mockAsciinemaWithClears.header as AsciinemaHeader,
mockAsciinemaWithClears.events as TestAsciinemaEvent[]
);
// Use reflection to call private method
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent('session1', filepath, {
response: mockResponse,
startTime: Date.now() / 1000,
});
// Wait for async operations to complete
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have header + final content only
expect(events.length).toBeGreaterThan(0);
// First event should be header with updated dimensions
const header = events[0] as AsciinemaHeader;
expect(header.version).toBe(2);
expect(header.width).toBe(100); // From last resize before clear
expect(header.height).toBe(30);
// Should only have content after the last clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(3); // Lines 9, 10, 11
expect(outputEvents[0][2]).toContain('Line 9: Final content');
expect(outputEvents[1][2]).toContain('Line 10: This should be visible');
expect(outputEvents[2][2]).toContain('Line 11: Last line');
// Should have exit event
const exitEvent = events.find((e) => Array.isArray(e) && e[0] === 'exit');
expect(exitEvent).toBeDefined();
});
it('should handle clear sequence in middle of line', async () => {
const filepath = createTestFile(
'clear-mid-line.cast',
mockAsciinemaWithClearMidLine.header as AsciinemaHeader,
mockAsciinemaWithClearMidLine.events as TestAsciinemaEvent[]
);
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent('session2', filepath, {
response: mockResponse,
startTime: Date.now() / 1000,
});
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should only have content after the clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(1); // Only "After clear"
expect(outputEvents[0][2]).toContain('After clear');
});
it('should not prune streams without clear sequences', async () => {
const filepath = createTestFile(
'no-clears.cast',
mockAsciinemaNoClears.header as AsciinemaHeader,
mockAsciinemaNoClears.events as TestAsciinemaEvent[]
);
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent('session3', filepath, {
response: mockResponse,
startTime: Date.now() / 1000,
});
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have all events
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(3); // All 3 lines
expect(outputEvents[0][2]).toContain('Line 1: No clears');
expect(outputEvents[1][2]).toContain('Line 2: Just regular');
expect(outputEvents[2][2]).toContain('Line 3: Should replay');
});
it('should fall back to non-pruning on read errors', async () => {
const nonExistentPath = path.join(tempDir, 'does-not-exist.cast');
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent('session4', nonExistentPath, {
response: mockResponse,
startTime: Date.now() / 1000,
});
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have called the fallback method (no data written due to missing file)
expect(writtenData.length).toBe(0);
});
it('should handle real-world Claude session with multiple clears', async () => {
// Use the actual real-world cast file
const filepath = path.join(__dirname, '../fixtures/asciinema/real-world-claude-session.cast');
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent('session5', filepath, {
response: mockResponse,
startTime: Date.now() / 1000,
});
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have pruned everything before the last clear
expect(events.length).toBeGreaterThan(0);
// First event should be header
const header = events[0] as AsciinemaHeader;
expect(header.version).toBe(2);
// Check that we're getting content after the last clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBeGreaterThan(0);
// The real file has 4 clear sequences, we should only see content after the last one
// Check that we have the welcome banner (appears after the last clear)
const welcomeContent = outputEvents.map((e) => e[2]).join('');
// Strip ANSI escape sequences for easier testing
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences are necessary for terminal output
const cleanContent = welcomeContent.replace(/\u001b\[[^m]*m/g, '');
expect(cleanContent).toContain('Welcome to Claude Code');
expect(cleanContent).toContain('/help for help');
// We should NOT see content from before the clears
expect(cleanContent).not.toContain('Some previous Claude output');
expect(cleanContent).not.toContain('cd workspaces'); // This was at the beginning
});
});