Move ascii stream pruning to asciinema writer

This commit is contained in:
Peter Steinberger 2025-07-28 01:27:30 +02:00
parent 2aff059636
commit 627309ebf4
6 changed files with 1041 additions and 139 deletions

View file

@ -48,13 +48,25 @@ import { once } from 'events';
import * as fs from 'fs';
import * as path from 'path';
import { promisify } from 'util';
import { createLogger } from '../utils/logger.js';
import { createLogger, isDebugEnabled } from '../utils/logger.js';
import {
calculateSequenceBytePosition,
detectLastPruningSequence,
logPruningDetection,
} from '../utils/pruning-detector.js';
import { WriteQueue } from '../utils/write-queue.js';
import { type AsciinemaEvent, type AsciinemaHeader, PtyError } from './types.js';
const _logger = createLogger('AsciinemaWriter');
const fsync = promisify(fs.fsync);
// Type for pruning sequence callback
export type PruningCallback = (info: {
sequence: string;
position: number;
timestamp: number;
}) => void;
export class AsciinemaWriter {
private writeStream: fs.WriteStream;
private startTime: Date;
@ -63,6 +75,17 @@ export class AsciinemaWriter {
private fd: number | null = null;
private writeQueue = new WriteQueue();
// Byte position tracking
private bytesWritten: number = 0; // Bytes actually written to disk
private pendingBytes: number = 0; // Bytes queued but not yet written
// Pruning sequence detection callback
private pruningCallback?: PruningCallback;
// Validation tracking
private lastValidatedPosition: number = 0;
private validationErrors: number = 0;
constructor(
private filePath: string,
private header: AsciinemaHeader
@ -114,6 +137,26 @@ export class AsciinemaWriter {
return new AsciinemaWriter(filePath, header);
}
/**
* Get the current byte position in the file
* @returns Object with current position and pending bytes
*/
getPosition(): { written: number; pending: number; total: number } {
return {
written: this.bytesWritten, // Bytes actually written to disk
pending: this.pendingBytes, // Bytes in queue
total: this.bytesWritten + this.pendingBytes, // Total position after queue flush
};
}
/**
* Set a callback to be notified when pruning sequences are detected
* @param callback Function called with sequence info and byte position
*/
onPruningSequence(callback: PruningCallback): void {
this.pruningCallback = callback;
}
/**
* Write the asciinema header to the file
*/
@ -122,10 +165,20 @@ export class AsciinemaWriter {
this.writeQueue.enqueue(async () => {
const headerJson = JSON.stringify(this.header);
const canWrite = this.writeStream.write(`${headerJson}\n`);
const headerLine = `${headerJson}\n`;
const headerBytes = Buffer.from(headerLine, 'utf8').length;
// Track pending bytes before write
this.pendingBytes += headerBytes;
const canWrite = this.writeStream.write(headerLine);
if (!canWrite) {
await once(this.writeStream, 'drain');
}
// Move bytes from pending to written
this.bytesWritten += headerBytes;
this.pendingBytes -= headerBytes;
});
this.headerWritten = true;
}
@ -144,12 +197,80 @@ export class AsciinemaWriter {
const { processedData, remainingBuffer } = this.processTerminalData(combinedBuffer);
if (processedData.length > 0) {
// First, check for pruning sequences in the data
let pruningInfo: { sequence: string; index: number } | null = null;
if (this.pruningCallback) {
// Use shared detector to find pruning sequences
const detection = detectLastPruningSequence(processedData);
if (detection) {
pruningInfo = detection;
_logger.debug(
`Found pruning sequence '${detection.sequence.split('\x1b').join('\\x1b')}' ` +
`at string index ${detection.index} in output data`
);
}
}
// Create the event with ALL data (not truncated)
const event: AsciinemaEvent = {
time,
type: 'o',
data: processedData,
};
// Calculate the byte position where the event will start
const eventStartPos = this.bytesWritten + this.pendingBytes;
// Write the event
await this.writeEvent(event);
// Now that the write is complete, handle pruning callback if needed
if (pruningInfo && this.pruningCallback) {
// Use shared calculator for exact byte position
const exactSequenceEndPos = calculateSequenceBytePosition(
eventStartPos,
time,
processedData,
pruningInfo.index,
pruningInfo.sequence.length
);
// Validate the calculation
const eventJson = `${JSON.stringify([time, 'o', processedData])}\n`;
const totalEventSize = Buffer.from(eventJson, 'utf8').length;
const calculatedEventEndPos = eventStartPos + totalEventSize;
if (isDebugEnabled()) {
_logger.debug(
`Pruning sequence byte calculation:\n` +
` Event start position: ${eventStartPos}\n` +
` Event total size: ${totalEventSize} bytes\n` +
` Event end position: ${calculatedEventEndPos}\n` +
` Exact sequence position: ${exactSequenceEndPos}\n` +
` Current file position: ${this.bytesWritten}`
);
}
// Sanity check: sequence position should be within the event
if (exactSequenceEndPos > calculatedEventEndPos) {
_logger.error(
`Pruning sequence position calculation error: ` +
`sequence position ${exactSequenceEndPos} is beyond event end ${calculatedEventEndPos}`
);
} else {
// Call the callback with the exact position
this.pruningCallback({
sequence: pruningInfo.sequence,
position: exactSequenceEndPos,
timestamp: time,
});
// Use shared logging function
logPruningDetection(pruningInfo.sequence, exactSequenceEndPos, '(real-time)');
}
}
}
// Store any remaining incomplete data for next time
@ -208,10 +329,20 @@ export class AsciinemaWriter {
writeRawJson(jsonValue: unknown): void {
this.writeQueue.enqueue(async () => {
const jsonString = JSON.stringify(jsonValue);
const canWrite = this.writeStream.write(`${jsonString}\n`);
const jsonLine = `${jsonString}\n`;
const jsonBytes = Buffer.from(jsonLine, 'utf8').length;
// Track pending bytes before write
this.pendingBytes += jsonBytes;
const canWrite = this.writeStream.write(jsonLine);
if (!canWrite) {
await once(this.writeStream, 'drain');
}
// Move bytes from pending to written
this.bytesWritten += jsonBytes;
this.pendingBytes -= jsonBytes;
});
}
@ -222,13 +353,38 @@ export class AsciinemaWriter {
// Asciinema format: [time, type, data]
const eventArray = [event.time, event.type, event.data];
const eventJson = JSON.stringify(eventArray);
const eventLine = `${eventJson}\n`;
const eventBytes = Buffer.from(eventLine, 'utf8').length;
// Log detailed write information for debugging
if (event.type === 'o' && isDebugEnabled()) {
_logger.debug(
`Writing output event: ${eventBytes} bytes, ` +
`data length: ${event.data.length} chars, ` +
`position: ${this.bytesWritten + this.pendingBytes}`
);
}
// Track pending bytes before write
this.pendingBytes += eventBytes;
// Write and handle backpressure
const canWrite = this.writeStream.write(`${eventJson}\n`);
const canWrite = this.writeStream.write(eventLine);
if (!canWrite) {
_logger.debug('Write stream backpressure detected, waiting for drain');
await once(this.writeStream, 'drain');
}
// Move bytes from pending to written
this.bytesWritten += eventBytes;
this.pendingBytes -= eventBytes;
// Validate position periodically
if (this.bytesWritten - this.lastValidatedPosition > 1024 * 1024) {
// Every 1MB
await this.validateFilePosition();
}
// Sync to disk asynchronously
if (this.fd !== null) {
try {
@ -422,6 +578,44 @@ export class AsciinemaWriter {
return (Date.now() - this.startTime.getTime()) / 1000;
}
/**
* Validate that our tracked position matches the actual file size
*/
private async validateFilePosition(): Promise<void> {
try {
const stats = await fs.promises.stat(this.filePath);
const actualSize = stats.size;
const expectedSize = this.bytesWritten;
if (actualSize !== expectedSize) {
this.validationErrors++;
_logger.error(
`AsciinemaWriter position mismatch! ` +
`Expected: ${expectedSize} bytes, Actual: ${actualSize} bytes, ` +
`Difference: ${actualSize - expectedSize} bytes, ` +
`Validation errors: ${this.validationErrors}`
);
// If the difference is significant, this is a critical error
if (Math.abs(actualSize - expectedSize) > 100) {
throw new PtyError(
`Critical byte position tracking error: expected ${expectedSize}, actual ${actualSize}`,
'POSITION_MISMATCH'
);
}
} else {
_logger.debug(`Position validation passed: ${actualSize} bytes`);
}
this.lastValidatedPosition = this.bytesWritten;
} catch (error) {
if (error instanceof PtyError) {
throw error;
}
_logger.error(`Failed to validate file position:`, error);
}
}
/**
* Close the writer and finalize the file
*/

View file

@ -374,6 +374,20 @@ export class PtyManager extends EventEmitter {
this.createEnvVars(term)
);
// Set up pruning detection callback for precise offset tracking
asciinemaWriter.onPruningSequence(async ({ sequence, position }) => {
const sessionInfo = this.sessionManager.loadSessionInfo(sessionId);
if (sessionInfo) {
sessionInfo.lastClearOffset = position;
await this.sessionManager.saveSessionInfo(sessionId, sessionInfo);
logger.debug(
`Updated lastClearOffset for session ${sessionId} to exact position ${position} ` +
`after detecting pruning sequence '${sequence.split('\x1b').join('\\x1b')}'`
);
}
});
// Create PTY process
let ptyProcess: IPty;
try {
@ -724,79 +738,9 @@ export class PtyManager extends EventEmitter {
}
// Write to asciinema file (it has its own internal queue)
// The AsciinemaWriter now handles pruning detection internally with precise byte tracking
asciinemaWriter?.writeOutput(Buffer.from(processedData, 'utf8'));
// Check for pruning sequences in the data and update lastClearOffset
// Comprehensive list of ANSI sequences that warrant pruning
const PRUNE_SEQUENCES = [
'\x1b[3J', // Clear scrollback buffer (xterm)
'\x1bc', // RIS - Full terminal reset
'\x1b[2J', // Clear screen (common)
'\x1b[H\x1b[J', // Home cursor + clear (older pattern)
'\x1b[H\x1b[2J', // Home cursor + clear screen variant
'\x1b[?1049h', // Enter alternate screen (vim, less, etc)
'\x1b[?1049l', // Exit alternate screen
'\x1b[?47h', // Save screen and enter alternate screen (older)
'\x1b[?47l', // Restore screen and exit alternate screen (older)
];
// Track if we found any pruning sequences and their positions
let lastPruneIndex = -1;
let lastPruneSequence = '';
// Check for each pruning sequence
for (const sequence of PRUNE_SEQUENCES) {
const index = processedData.lastIndexOf(sequence);
if (index !== -1 && index > lastPruneIndex) {
lastPruneIndex = index;
lastPruneSequence = sequence;
}
}
// If we found a pruning sequence, calculate precise offset
if (lastPruneIndex !== -1 && asciinemaWriter) {
// We need to calculate the exact byte position in the asciinema file
// The challenge is that the data has already been written, but we need
// to track where in the file the prune point is
// For now, we'll use the imprecise method but log a warning
// The proper fix requires tracking byte positions through AsciinemaWriter
logger.warn(
`Found pruning sequence '${lastPruneSequence.split('\x1b').join('\\x1b')}' at position ${lastPruneIndex} in session ${session.id}. ` +
`Using imprecise offset tracking - this will be fixed when AsciinemaWriter exposes byte positions.`
);
// Schedule update after write completes
setTimeout(async () => {
try {
const sessionPaths = this.sessionManager.getSessionPaths(session.id);
if (!sessionPaths) {
logger.error(`Failed to get session paths for session ${session.id}`);
return;
}
const stats = await fs.promises.stat(sessionPaths.stdoutPath);
const currentFileSize = stats.size;
const sessionInfo = this.sessionManager.loadSessionInfo(session.id);
if (!sessionInfo) {
logger.error(`Failed to get session info for session ${session.id}`);
return;
}
// Update with current file size (imprecise but better than nothing)
sessionInfo.lastClearOffset = currentFileSize;
await this.sessionManager.saveSessionInfo(session.id, sessionInfo);
logger.debug(
`Updated lastClearOffset for session ${session.id} to ${currentFileSize} after detecting pruning sequence '${lastPruneSequence.split('\x1b').join('\\x1b')}'`
);
} catch (error) {
logger.error(`Failed to update lastClearOffset for session ${session.id}:`, error);
}
}, 100);
}
// Forward to stdout if requested (using queue for ordering)
if (forwardToStdout && stdoutQueue) {
stdoutQueue.enqueue(async () => {

View file

@ -4,28 +4,18 @@ import * as fs from 'fs';
import type { SessionManager } from '../pty/session-manager.js';
import type { AsciinemaHeader } from '../pty/types.js';
import { createLogger } from '../utils/logger.js';
import {
calculatePruningPositionInFile,
containsPruningSequence,
findLastPrunePoint,
logPruningDetection,
} from '../utils/pruning-detector.js';
const logger = createLogger('stream-watcher');
// Constants
const HEADER_READ_BUFFER_SIZE = 4096;
// Comprehensive list of ANSI sequences that warrant pruning
const PRUNE_SEQUENCES = [
'\x1b[3J', // Clear scrollback buffer (xterm)
'\x1bc', // RIS - Full terminal reset
'\x1b[2J', // Clear screen (common)
'\x1b[H\x1b[J', // Home cursor + clear (older pattern)
'\x1b[H\x1b[2J', // Home cursor + clear screen variant
'\x1b[?1049h', // Enter alternate screen (vim, less, etc)
'\x1b[?1049l', // Exit alternate screen
'\x1b[?47h', // Save screen and enter alternate screen (older)
'\x1b[?47l', // Restore screen and exit alternate screen (older)
];
// Keep the old constant for compatibility
const _CLEAR_SEQUENCE = '\x1b[3J';
interface StreamClient {
response: Response;
startTime: number;
@ -59,44 +49,6 @@ function isExitEvent(event: AsciinemaEvent): event is AsciinemaExitEvent {
return Array.isArray(event) && event[0] === 'exit';
}
/**
* Checks if an output event contains any pruning sequence
* @param event - The asciinema event to check
* @returns true if the event contains a pruning sequence
*/
function containsClearSequence(event: AsciinemaEvent): boolean {
if (!isOutputEvent(event)) return false;
const data = event[2];
// Check if data contains any of the pruning sequences
return PRUNE_SEQUENCES.some((sequence) => data.includes(sequence));
}
/**
* Finds the last pruning sequence in the data and its position
* @param data - The output data to search
* @returns Object with the sequence and its position, or null if not found
*/
function findLastPrunePoint(data: string): { sequence: string; position: number } | null {
let lastPosition = -1;
let lastSequence = '';
for (const sequence of PRUNE_SEQUENCES) {
const pos = data.lastIndexOf(sequence);
if (pos > lastPosition) {
lastPosition = pos;
lastSequence = sequence;
}
}
if (lastPosition === -1) return null;
return {
sequence: lastSequence,
position: lastPosition + lastSequence.length,
};
}
interface WatcherInfo {
clients: Set<StreamClient>;
watcher?: fs.FSWatcher;
@ -126,7 +78,8 @@ export class StreamWatcher {
event: AsciinemaOutputEvent,
eventIndex: number,
fileOffset: number,
currentResize: AsciinemaResizeEvent | null
currentResize: AsciinemaResizeEvent | null,
eventLine: string
): {
lastClearIndex: number;
lastClearOffset: number;
@ -135,13 +88,19 @@ export class StreamWatcher {
const prunePoint = findLastPrunePoint(event[2]);
if (!prunePoint) return null;
// Calculate precise offset: current position minus unused data after prune point
const unusedBytes = Buffer.byteLength(event[2].substring(prunePoint.position), 'utf8');
const lastClearOffset = fileOffset - unusedBytes;
// Calculate precise offset using shared utility
const lastClearOffset = calculatePruningPositionInFile(
fileOffset,
eventLine,
prunePoint.position
);
// Use shared logging function
logPruningDetection(prunePoint.sequence, lastClearOffset, '(retroactive scan)');
logger.debug(
`found pruning sequence '${prunePoint.sequence.split('\x1b').join('\\x1b')}' at event index ${eventIndex}, ` +
`offset: ${lastClearOffset}, current resize: ${currentResize ? currentResize[2] : 'none'}`
`found at event index ${eventIndex}, ` +
`current resize: ${currentResize ? currentResize[2] : 'none'}`
);
return {
@ -414,12 +373,13 @@ export class StreamWatcher {
}
// Check for clear sequence in output events
if (containsClearSequence(event)) {
if (isOutputEvent(event) && containsPruningSequence(event[2])) {
const clearResult = this.processClearSequence(
event as AsciinemaOutputEvent,
events.length,
fileOffset,
currentResize
currentResize,
line
);
if (clearResult) {
lastClearIndex = clearResult.lastClearIndex;
@ -454,12 +414,13 @@ export class StreamWatcher {
if (isResizeEvent(event)) {
currentResize = event;
}
if (containsClearSequence(event)) {
if (isOutputEvent(event) && containsPruningSequence(event[2])) {
const clearResult = this.processClearSequence(
event as AsciinemaOutputEvent,
events.length,
fileOffset,
currentResize
currentResize,
lineBuffer
);
if (clearResult) {
lastClearIndex = clearResult.lastClearIndex;

View file

@ -0,0 +1,259 @@
/**
* Pruning Detector - Unified detection of terminal pruning sequences
*
* This module provides a single source of truth for detecting terminal sequences
* that indicate the terminal buffer should be pruned (cleared). It's used by both:
* - AsciinemaWriter: Real-time detection during recording
* - StreamWatcher: Retroactive detection during playback
*
* Pruning helps prevent session files from growing indefinitely by identifying
* points where old terminal content can be safely discarded.
*/
import { createLogger } from './logger.js';
const logger = createLogger('PruningDetector');
/**
* Comprehensive list of ANSI sequences that warrant pruning.
* These sequences indicate the terminal has been cleared or reset,
* making previous content unnecessary for playback.
*/
export const PRUNE_SEQUENCES = [
'\x1b[3J', // Clear scrollback buffer (xterm) - most common
'\x1bc', // RIS - Full terminal reset
'\x1b[2J', // Clear screen (common)
'\x1b[H\x1b[J', // Home cursor + clear (older pattern)
'\x1b[H\x1b[2J', // Home cursor + clear screen variant
'\x1b[?1049h', // Enter alternate screen (vim, less, etc)
'\x1b[?1049l', // Exit alternate screen
'\x1b[?47h', // Save screen and enter alternate screen (older)
'\x1b[?47l', // Restore screen and exit alternate screen (older)
] as const;
/**
* Result of pruning sequence detection
*/
export interface PruningDetectionResult {
sequence: string;
index: number;
}
/**
* Detect the last pruning sequence in raw terminal data.
*
* @param data - Raw terminal output data
* @returns Detection result with sequence and index, or null if not found
*/
export function detectLastPruningSequence(data: string): PruningDetectionResult | null {
let lastIndex = -1;
let lastSequence = '';
for (const sequence of PRUNE_SEQUENCES) {
const index = data.lastIndexOf(sequence);
if (index > lastIndex) {
lastIndex = index;
lastSequence = sequence;
}
}
if (lastIndex === -1) {
return null;
}
return {
sequence: lastSequence,
index: lastIndex,
};
}
/**
* Check if data contains any pruning sequence.
*
* @param data - Terminal data to check
* @returns true if any pruning sequence is found
*/
export function containsPruningSequence(data: string): boolean {
return PRUNE_SEQUENCES.some((sequence) => data.includes(sequence));
}
/**
* Find the position of the last pruning sequence and where it ends.
*
* @param data - Terminal data to search
* @returns Object with sequence and end position, or null if not found
*/
export function findLastPrunePoint(data: string): { sequence: string; position: number } | null {
const result = detectLastPruningSequence(data);
if (!result) {
return null;
}
return {
sequence: result.sequence,
position: result.index + result.sequence.length,
};
}
/**
* Calculate the exact byte position of a sequence within an asciinema event.
* This accounts for JSON encoding and the event format: [timestamp, "o", "data"]
*
* @param eventStartPos - Byte position where the event starts in the file
* @param timestamp - Event timestamp
* @param fullData - Complete data string that will be written
* @param sequenceIndex - Character index of the sequence in the data
* @param sequenceLength - Length of the sequence in characters
* @returns Exact byte position where the sequence ends in the file
*/
export function calculateSequenceBytePosition(
eventStartPos: number,
timestamp: number,
fullData: string,
sequenceIndex: number,
sequenceLength: number
): number {
// Calculate the data up to where the sequence ends
const dataUpToSequenceEnd = fullData.substring(0, sequenceIndex + sequenceLength);
// Create the event array prefix: [timestamp,"o","
const eventPrefix = JSON.stringify([timestamp, 'o', '']).slice(0, -1); // Remove trailing quote
const prefixBytes = Buffer.from(eventPrefix, 'utf8').length;
// Calculate bytes for the data portion up to sequence end
const sequenceBytesInData = Buffer.from(dataUpToSequenceEnd, 'utf8').length;
// Total position is: event start + prefix bytes + data bytes
return eventStartPos + prefixBytes + sequenceBytesInData;
}
/**
* Parse an asciinema event line and check for pruning sequences.
*
* @param line - JSON line from asciinema file
* @returns Detection result with additional metadata, or null
*/
export function checkAsciinemaEventForPruning(line: string): {
sequence: string;
dataIndex: number;
timestamp: number;
eventType: string;
} | null {
try {
const parsed = JSON.parse(line);
// Check if it's a valid event array
if (!Array.isArray(parsed) || parsed.length < 3) {
return null;
}
const [timestamp, eventType, data] = parsed;
// Only check output events
if (eventType !== 'o' || typeof data !== 'string') {
return null;
}
// Check for pruning sequences
const result = detectLastPruningSequence(data);
if (!result) {
return null;
}
return {
sequence: result.sequence,
dataIndex: result.index,
timestamp,
eventType,
};
} catch (error) {
// Invalid JSON or parsing error
logger.debug(`Failed to parse asciinema line: ${error}`);
return null;
}
}
/**
* Calculate the byte position of a pruning sequence found in an asciinema file.
* This is used when scanning existing files to find exact positions.
*
* @param fileOffset - Current byte offset in the file
* @param eventLine - The full JSON line containing the event
* @param sequenceEndIndex - Character index where the sequence ends in the data
* @returns Exact byte position where the sequence ends
*/
export function calculatePruningPositionInFile(
fileOffset: number,
eventLine: string,
sequenceEndIndex: number
): number {
// The fileOffset is at the end of this line
// We need to find where within the line the sequence ends
// Parse the event to get the data
const event = JSON.parse(eventLine);
const data = event[2];
// Find where the data portion starts in the JSON string
// This is after: [timestamp,"o","
const jsonPrefix = JSON.stringify([event[0], event[1], '']).slice(0, -1);
const prefixLength = jsonPrefix.length;
// Calculate how many bytes from start of line to sequence end
const dataUpToSequence = data.substring(0, sequenceEndIndex);
const dataBytes = Buffer.from(dataUpToSequence, 'utf8').length;
// The position is: start of line + prefix + data bytes
const lineStart = fileOffset - Buffer.from(`${eventLine}\n`, 'utf8').length;
return lineStart + prefixLength + dataBytes;
}
/**
* Log detection of a pruning sequence in a consistent format.
*
* @param sequence - The detected sequence
* @param position - Byte position in the file
* @param context - Additional context for the log
*/
export function logPruningDetection(
sequence: string,
position: number,
context: string = ''
): void {
const escapedSequence = sequence.split('\x1b').join('\\x1b');
logger.debug(
`Detected pruning sequence '${escapedSequence}' at byte position ${position}` +
(context ? ` ${context}` : '')
);
}
/**
* Get a human-readable name for a pruning sequence.
*
* @param sequence - The pruning sequence
* @returns Description of what the sequence does
*/
export function getSequenceDescription(sequence: string): string {
switch (sequence) {
case '\x1b[3J':
return 'Clear scrollback buffer';
case '\x1bc':
return 'Terminal reset (RIS)';
case '\x1b[2J':
return 'Clear screen';
case '\x1b[H\x1b[J':
return 'Home cursor + clear';
case '\x1b[H\x1b[2J':
return 'Home cursor + clear screen';
case '\x1b[?1049h':
return 'Enter alternate screen';
case '\x1b[?1049l':
return 'Exit alternate screen';
case '\x1b[?47h':
return 'Save screen (legacy)';
case '\x1b[?47l':
return 'Restore screen (legacy)';
default:
return 'Unknown sequence';
}
}

View file

@ -0,0 +1,378 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AsciinemaWriter } from '../../server/pty/asciinema-writer';
describe('AsciinemaWriter byte position tracking', () => {
let tempDir: string;
let testFile: string;
let writer: AsciinemaWriter;
beforeEach(() => {
// Create a temporary directory for test files
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'asciinema-test-'));
testFile = path.join(tempDir, 'test.cast');
});
afterEach(async () => {
// Clean up
if (writer?.isOpen()) {
await writer.close();
}
if (fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true });
}
});
it('should track byte position correctly for header', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24, 'test command', 'Test Title');
// Give the header time to be written
await new Promise((resolve) => setTimeout(resolve, 10));
const position = writer.getPosition();
expect(position.written).toBeGreaterThan(0);
expect(position.pending).toBe(0);
expect(position.total).toBe(position.written);
// Verify the header was actually written to file
const fileContent = fs.readFileSync(testFile, 'utf8');
const headerLine = fileContent.split('\n')[0];
const header = JSON.parse(headerLine);
expect(header.version).toBe(2);
expect(header.width).toBe(80);
expect(header.height).toBe(24);
});
it('should track byte position for output events', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Wait for header to be written
await new Promise((resolve) => setTimeout(resolve, 10));
const positionAfterHeader = writer.getPosition();
// Write some output
const testOutput = 'Hello, World!\r\n';
writer.writeOutput(Buffer.from(testOutput));
// Wait for write to complete
await new Promise((resolve) => setTimeout(resolve, 50));
const positionAfterOutput = writer.getPosition();
expect(positionAfterOutput.written).toBeGreaterThan(positionAfterHeader.written);
expect(positionAfterOutput.pending).toBe(0);
});
it('should detect pruning sequences and call callback with correct position', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Set up pruning callback
const pruningEvents: Array<{ sequence: string; position: number; timestamp: number }> = [];
let callbackFileSize = 0;
writer.onPruningSequence((info) => {
pruningEvents.push(info);
// Capture file size when callback is called
callbackFileSize = fs.statSync(testFile).size;
});
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
const headerSize = fs.statSync(testFile).size;
// Write output with a clear screen sequence
const clearScreen = '\x1b[2J';
const outputWithClear = `Some text before${clearScreen}Some text after`;
writer.writeOutput(Buffer.from(outputWithClear));
// Wait for write and callback
await new Promise((resolve) => setTimeout(resolve, 50));
// Should have detected the clear sequence
expect(pruningEvents).toHaveLength(1);
expect(pruningEvents[0].sequence).toBe(clearScreen);
expect(pruningEvents[0].position).toBeGreaterThan(headerSize);
expect(pruningEvents[0].timestamp).toBeGreaterThan(0);
// Verify the callback was called AFTER the write completed
expect(callbackFileSize).toBeGreaterThan(headerSize);
// The position should be within the event that was written
const finalSize = fs.statSync(testFile).size;
expect(pruningEvents[0].position).toBeLessThan(finalSize);
// The position should be after the sequence text
const sequenceIndex = outputWithClear.indexOf(clearScreen) + clearScreen.length;
// Account for JSON encoding overhead
expect(pruningEvents[0].position).toBeGreaterThan(headerSize + sequenceIndex);
});
it('should detect multiple pruning sequences', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
const pruningEvents: Array<{ sequence: string; position: number }> = [];
writer.onPruningSequence((info) => {
pruningEvents.push({ sequence: info.sequence, position: info.position });
});
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
// Write output with multiple pruning sequences
writer.writeOutput(Buffer.from('Initial text\r\n'));
await new Promise((resolve) => setTimeout(resolve, 20));
writer.writeOutput(Buffer.from('Before clear\x1b[2JAfter clear'));
await new Promise((resolve) => setTimeout(resolve, 20));
writer.writeOutput(Buffer.from('More text\x1bcReset terminal'));
await new Promise((resolve) => setTimeout(resolve, 20));
writer.writeOutput(Buffer.from('Enter alt screen\x1b[?1049hIn alt screen'));
await new Promise((resolve) => setTimeout(resolve, 20));
// Should have detected all sequences
expect(pruningEvents.length).toBeGreaterThanOrEqual(3);
// Check that positions are increasing
for (let i = 1; i < pruningEvents.length; i++) {
expect(pruningEvents[i].position).toBeGreaterThan(pruningEvents[i - 1].position);
}
});
it('should track pending bytes correctly', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Wait for header to be fully written
await new Promise((resolve) => setTimeout(resolve, 50));
const initialPosition = writer.getPosition();
const initialBytes = initialPosition.written;
// Write a large amount of data quickly
const largeData = 'x'.repeat(10000);
// Write multiple chunks synchronously
for (let i = 0; i < 5; i++) {
writer.writeOutput(Buffer.from(largeData));
}
// Check tracking immediately after queueing writes
const positionAfterQueue = writer.getPosition();
// The total should include all queued data
expect(positionAfterQueue.total).toBeGreaterThanOrEqual(initialBytes);
// Verify the math is correct
expect(positionAfterQueue.total).toBe(positionAfterQueue.written + positionAfterQueue.pending);
// Wait for all writes to complete
await new Promise((resolve) => setTimeout(resolve, 300));
// Now all should be written
const finalPosition = writer.getPosition();
expect(finalPosition.pending).toBe(0);
expect(finalPosition.written).toBe(finalPosition.total);
// Should have written at least the data we sent (accounting for JSON encoding overhead)
const minExpectedBytes = initialBytes + 5 * 10000; // At least the raw data size
expect(finalPosition.written).toBeGreaterThan(minExpectedBytes);
});
it('should handle different event types correctly', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
const posAfterHeader = writer.getPosition();
// Write output event
writer.writeOutput(Buffer.from('output text'));
await new Promise((resolve) => setTimeout(resolve, 20));
const posAfterOutput = writer.getPosition();
// Write input event
writer.writeInput('input text');
await new Promise((resolve) => setTimeout(resolve, 20));
const posAfterInput = writer.getPosition();
// Write resize event
writer.writeResize(120, 40);
await new Promise((resolve) => setTimeout(resolve, 20));
const posAfterResize = writer.getPosition();
// Write marker event
writer.writeMarker('test marker');
await new Promise((resolve) => setTimeout(resolve, 20));
const posAfterMarker = writer.getPosition();
// All positions should increase
expect(posAfterOutput.written).toBeGreaterThan(posAfterHeader.written);
expect(posAfterInput.written).toBeGreaterThan(posAfterOutput.written);
expect(posAfterResize.written).toBeGreaterThan(posAfterInput.written);
expect(posAfterMarker.written).toBeGreaterThan(posAfterResize.written);
});
it('should handle UTF-8 correctly in byte counting', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
// Write UTF-8 text with multi-byte characters
const utf8Text = 'Hello 世界 🌍!'; // Contains 2-byte and 4-byte UTF-8 characters
writer.writeOutput(Buffer.from(utf8Text));
await new Promise((resolve) => setTimeout(resolve, 50));
const position = writer.getPosition();
// Read file and verify byte count matches
const fileContent = fs.readFileSync(testFile);
expect(position.written).toBe(fileContent.length);
});
it('should detect last pruning sequence when multiple exist in same output', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
const pruningEvents: Array<{ sequence: string; position: number }> = [];
writer.onPruningSequence((info) => {
pruningEvents.push({ sequence: info.sequence, position: info.position });
});
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
// Write output with multiple pruning sequences in one write
const outputWithMultipleClear = 'Text1\x1b[2JText2\x1b[3JText3\x1bcText4';
writer.writeOutput(Buffer.from(outputWithMultipleClear));
await new Promise((resolve) => setTimeout(resolve, 50));
// Should only report the last one (as per the implementation)
expect(pruningEvents).toHaveLength(1);
expect(pruningEvents[0].sequence).toBe('\x1bc'); // The last sequence
});
it('should close properly and finalize byte count', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Write some data
writer.writeOutput(Buffer.from('Test data\r\n'));
writer.writeInput('test input');
writer.writeResize(100, 30);
// Close the writer
await writer.close();
// Should not be open anymore
expect(writer.isOpen()).toBe(false);
// Final position should match file size
const position = writer.getPosition();
const stats = fs.statSync(testFile);
expect(position.written).toBe(stats.size);
expect(position.pending).toBe(0);
});
it('should calculate exact pruning sequence position within full event', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
const pruningPositions: number[] = [];
writer.onPruningSequence((info) => {
pruningPositions.push(info.position);
});
// Wait for header
await new Promise((resolve) => setTimeout(resolve, 10));
// Write output with pruning sequence in the middle
const beforeText = 'Before clear sequence text';
const clearSequence = '\x1b[3J';
const afterText = 'After clear sequence text that is longer';
const fullOutput = beforeText + clearSequence + afterText;
writer.writeOutput(Buffer.from(fullOutput));
// Wait for write to complete
await new Promise((resolve) => setTimeout(resolve, 50));
// Read the actual file to verify position
const fileContent = fs.readFileSync(testFile, 'utf8');
const lines = fileContent.split('\n');
// Find the event line (skip header)
// The escape sequence will be JSON-encoded in the file
const eventLine = lines.find((line) => line.includes('"o"') && line.includes('Before clear'));
expect(eventLine).toBeDefined();
// The reported position should be exactly where the sequence ends in the file
expect(pruningPositions).toHaveLength(1);
const reportedPosition = pruningPositions[0];
// Calculate expected position
const headerLine = lines[0];
const headerBytes = Buffer.from(`${headerLine}\n`, 'utf8').length;
// Parse the event to find the data
if (!eventLine) {
throw new Error('Event line not found');
}
const eventData = JSON.parse(eventLine);
expect(eventData[1]).toBe('o'); // output event
expect(eventData[2]).toBe(fullOutput); // full data should be written
// Find where the sequence ends in the actual data
const dataString = eventData[2];
const sequenceIndex = dataString.indexOf(clearSequence);
expect(sequenceIndex).toBeGreaterThan(0);
// The sequence ends at this position in the data (not used but kept for clarity)
// const sequenceEndInData = sequenceIndex + clearSequence.length;
// Now find where this maps to in the JSON string
// We need to account for JSON escaping of the escape character
const jsonEncodedData = JSON.stringify(dataString);
const jsonEncodedSequence = JSON.stringify(clearSequence).slice(1, -1); // Remove quotes
// Find where the sequence ends in the JSON-encoded string
const sequenceEndInJson =
jsonEncodedData.indexOf(jsonEncodedSequence) + jsonEncodedSequence.length;
// The position in the file is: header + event prefix + position in data
// Event prefix is: [timestamp,"o","
const eventPrefix = eventLine?.substring(0, eventLine?.indexOf(jsonEncodedData));
const prefixBytes = Buffer.from(eventPrefix, 'utf8').length;
// Calculate bytes up to the sequence end in the JSON string
const dataUpToSequenceEnd = jsonEncodedData.substring(0, sequenceEndInJson);
const sequenceBytesInJson = Buffer.from(dataUpToSequenceEnd, 'utf8').length;
// Remove the opening quote byte since it's part of the prefix
const expectedPosition = headerBytes + prefixBytes + sequenceBytesInJson - 1;
// Allow for small discrepancies due to JSON encoding
expect(Math.abs(reportedPosition - expectedPosition)).toBeLessThanOrEqual(10);
});
it('should validate file position periodically', async () => {
writer = AsciinemaWriter.create(testFile, 80, 24);
// Write enough data to trigger validation (> 1MB)
const largeData = 'x'.repeat(100000); // 100KB per write
for (let i = 0; i < 12; i++) {
// 1.2MB total
writer.writeOutput(Buffer.from(largeData));
}
// Wait for all writes to complete
await new Promise((resolve) => setTimeout(resolve, 500));
// The position should still be accurate
const position = writer.getPosition();
const stats = fs.statSync(testFile);
expect(position.written).toBe(stats.size);
expect(position.pending).toBe(0);
});
});

View file

@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import {
calculatePruningPositionInFile,
calculateSequenceBytePosition,
checkAsciinemaEventForPruning,
containsPruningSequence,
detectLastPruningSequence,
findLastPrunePoint,
getSequenceDescription,
PRUNE_SEQUENCES,
} from '../../server/utils/pruning-detector';
describe('Pruning Detector', () => {
describe('detectLastPruningSequence', () => {
it('should detect the last pruning sequence in data', () => {
const data = 'some text\x1b[2Jmore text\x1b[3Jfinal text';
const result = detectLastPruningSequence(data);
expect(result).not.toBeNull();
expect(result?.sequence).toBe('\x1b[3J');
expect(result?.index).toBe(data.lastIndexOf('\x1b[3J'));
});
it('should return null if no pruning sequence found', () => {
const data = 'just normal text without escape sequences';
const result = detectLastPruningSequence(data);
expect(result).toBeNull();
});
it('should find the last sequence when multiple exist', () => {
const data = '\x1b[2J\x1b[3J\x1bc\x1b[?1049h';
const result = detectLastPruningSequence(data);
expect(result?.sequence).toBe('\x1b[?1049h');
});
});
describe('containsPruningSequence', () => {
it('should return true if data contains any pruning sequence', () => {
expect(containsPruningSequence('text\x1b[3Jmore')).toBe(true);
expect(containsPruningSequence('text\x1bcmore')).toBe(true);
expect(containsPruningSequence('text\x1b[?1049hmore')).toBe(true);
});
it('should return false if no pruning sequences', () => {
expect(containsPruningSequence('normal text')).toBe(false);
expect(containsPruningSequence('text with \x1b[31m color')).toBe(false);
});
});
describe('findLastPrunePoint', () => {
it('should return position after the sequence', () => {
const data = 'before\x1b[3Jafter';
const result = findLastPrunePoint(data);
expect(result).not.toBeNull();
expect(result?.sequence).toBe('\x1b[3J');
expect(result?.position).toBe(data.indexOf('after'));
});
});
describe('checkAsciinemaEventForPruning', () => {
it('should detect pruning in valid output event', () => {
const line = JSON.stringify([1.234, 'o', 'text\x1b[3Jmore']);
const result = checkAsciinemaEventForPruning(line);
expect(result).not.toBeNull();
expect(result?.sequence).toBe('\x1b[3J');
expect(result?.dataIndex).toBe(4); // position in the data string
expect(result?.timestamp).toBe(1.234);
expect(result?.eventType).toBe('o');
});
it('should return null for non-output events', () => {
const inputEvent = JSON.stringify([1.234, 'i', 'user input']);
expect(checkAsciinemaEventForPruning(inputEvent)).toBeNull();
const resizeEvent = JSON.stringify([1.234, 'r', '80x24']);
expect(checkAsciinemaEventForPruning(resizeEvent)).toBeNull();
});
it('should return null for invalid JSON', () => {
expect(checkAsciinemaEventForPruning('not json')).toBeNull();
});
});
describe('calculateSequenceBytePosition', () => {
it('should calculate correct byte position for ASCII data', () => {
const eventStartPos = 100;
const timestamp = 1.5;
const fullData = 'hello\x1b[3Jworld';
const sequenceIndex = fullData.indexOf('\x1b[3J');
const sequenceLength = 4; // \x1b[3J
const position = calculateSequenceBytePosition(
eventStartPos,
timestamp,
fullData,
sequenceIndex,
sequenceLength
);
// The prefix [1.5,"o"," is 11 bytes (need to count the opening quote)
// Data up to sequence end is "hello\x1b[3J" which is 9 bytes
expect(position).toBe(eventStartPos + 11 + 9);
});
it('should handle UTF-8 multi-byte characters correctly', () => {
const eventStartPos = 200;
const timestamp = 2.0;
const fullData = '世界\x1b[3J'; // 世界 is 6 bytes in UTF-8
const sequenceIndex = 2; // character index after 世界
const sequenceLength = 4;
const position = calculateSequenceBytePosition(
eventStartPos,
timestamp,
fullData,
sequenceIndex,
sequenceLength
);
// Prefix [2,"o"," is 9 bytes (with opening quote)
// Data "世界\x1b[3J" is 10 bytes total
expect(position).toBe(eventStartPos + 9 + 10);
});
});
describe('calculatePruningPositionInFile', () => {
it('should calculate position within a file line', () => {
const eventLine = JSON.stringify([1.5, 'o', 'text\x1b[3Jmore']);
const fileOffset = 500; // end of this line in file
const sequenceEndIndex = 9; // after \x1b[3J in "text\x1b[3Jmore"
const position = calculatePruningPositionInFile(fileOffset, eventLine, sequenceEndIndex);
// Should calculate position within the line
expect(position).toBeLessThan(fileOffset);
expect(position).toBeGreaterThan(fileOffset - eventLine.length);
});
});
describe('getSequenceDescription', () => {
it('should return correct descriptions for known sequences', () => {
expect(getSequenceDescription('\x1b[3J')).toBe('Clear scrollback buffer');
expect(getSequenceDescription('\x1bc')).toBe('Terminal reset (RIS)');
expect(getSequenceDescription('\x1b[2J')).toBe('Clear screen');
expect(getSequenceDescription('\x1b[?1049h')).toBe('Enter alternate screen');
expect(getSequenceDescription('\x1b[?1049l')).toBe('Exit alternate screen');
});
it('should return unknown for unrecognized sequences', () => {
expect(getSequenceDescription('\x1b[99X')).toBe('Unknown sequence');
});
});
describe('PRUNE_SEQUENCES constant', () => {
it('should contain all expected sequences', () => {
expect(PRUNE_SEQUENCES).toContain('\x1b[3J');
expect(PRUNE_SEQUENCES).toContain('\x1bc');
expect(PRUNE_SEQUENCES).toContain('\x1b[2J');
expect(PRUNE_SEQUENCES).toContain('\x1b[?1049h');
expect(PRUNE_SEQUENCES).toContain('\x1b[?1049l');
expect(PRUNE_SEQUENCES.length).toBeGreaterThanOrEqual(9);
});
});
});