diff --git a/mac/VibeTunnel/Core/Services/SessionMonitor.swift b/mac/VibeTunnel/Core/Services/SessionMonitor.swift index a702062f..a6bd3798 100644 --- a/mac/VibeTunnel/Core/Services/SessionMonitor.swift +++ b/mac/VibeTunnel/Core/Services/SessionMonitor.swift @@ -5,14 +5,18 @@ import os.log /// Server session information returned by the API struct ServerSessionInfo: Codable { let id: String - let command: String + let command: [String] // Changed from String to [String] to match server + let name: String? // Added missing field let workingDir: String let status: String let exitCode: Int? let startedAt: String let lastModified: String - let pid: Int + let pid: Int? // Made optional since it might not exist for all sessions + let initialCols: Int? // Added missing field + let initialRows: Int? // Added missing field let activityStatus: ActivityStatus? + let source: String? // Added for HQ mode var isRunning: Bool { status == "running" @@ -121,6 +125,12 @@ final class SessionMonitor { self.lastFetch = Date() logger.debug("Fetched \(sessionsArray.count) sessions, \(sessionsDict.values.filter { $0.isRunning }.count) running") + + // Debug: Log session details + for session in sessionsArray { + let pidStr = session.pid.map { String($0) } ?? "nil" + logger.debug("Session \(session.id): status=\(session.status), isRunning=\(session.isRunning), pid=\(pidStr)") + } // Update WindowTracker WindowTracker.shared.updateFromSessions(sessionsArray) @@ -129,6 +139,7 @@ final class SessionMonitor { if !(error is URLError) { self.lastError = error } + logger.error("Failed to fetch sessions: \(error, privacy: .public)") self.sessions = [:] self.lastFetch = Date() // Still update timestamp to avoid hammering } diff --git a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift index 3d7480f2..9a552f15 100644 --- a/mac/VibeTunnel/Presentation/Views/MenuBarView.swift +++ b/mac/VibeTunnel/Presentation/Views/MenuBarView.swift @@ -38,6 +38,9 @@ struct MenuBarView: View { SessionCountView(count: sessionMonitor.sessionCount) .padding(.horizontal, 12) .padding(.vertical, 8) + .onAppear { + print("[MenuBarView] Session count: \(sessionMonitor.sessionCount), total sessions: \(sessionMonitor.sessions.count)") + } // Session list with clickable items if !sessionMonitor.sessions.isEmpty { @@ -177,8 +180,13 @@ struct MenuBarView: View { } .frame(minWidth: 200) .task { - // Initial delay to ensure auth token is set - try? await Task.sleep(for: .milliseconds(500)) + // Wait for server to be running before fetching sessions + while !serverManager.isRunning { + try? await Task.sleep(for: .milliseconds(500)) + } + + // Give the server a moment to fully initialize after starting + try? await Task.sleep(for: .milliseconds(100)) // Force initial refresh await sessionMonitor.refresh() @@ -334,9 +342,11 @@ struct SessionRowView: View { .padding(.trailing, 8) } - Text("PID: \(session.value.pid)") - .font(.system(size: 11)) - .foregroundColor(.secondary) + if let pid = session.value.pid { + Text("PID: \(pid)") + .font(.system(size: 11)) + .foregroundColor(.secondary) + } } .padding(.vertical, 2) .padding(.horizontal, 8) diff --git a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift index 1b94bd1e..48222b3c 100644 --- a/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift +++ b/mac/VibeTunnel/Presentation/Views/SessionDetailView.swift @@ -30,11 +30,15 @@ struct SessionDetailView: View { // Session Information VStack(alignment: .leading, spacing: 16) { DetailRow(label: "Session ID", value: session.id) - DetailRow(label: "Command", value: session.command) + DetailRow(label: "Command", value: session.command.joined(separator: " ")) DetailRow(label: "Working Directory", value: session.workingDir) DetailRow(label: "Status", value: session.status.capitalized) DetailRow(label: "Started At", value: formatDate(session.startedAt)) DetailRow(label: "Last Modified", value: formatDate(session.lastModified)) + + if let pid = session.pid { + DetailRow(label: "Process ID", value: "\(pid)") + } if let exitCode = session.exitCode { DetailRow(label: "Exit Code", value: "\(exitCode)") @@ -71,7 +75,11 @@ struct SessionDetailView: View { private func updateWindowTitle() { let dir = URL(fileURLWithPath: session.workingDir).lastPathComponent - windowTitle = "\(dir) β€” VibeTunnel (PID: \(session.pid))" + if let pid = session.pid { + windowTitle = "\(dir) β€” VibeTunnel (PID: \(pid))" + } else { + windowTitle = "\(dir) β€” VibeTunnel" + } } private func formatDate(_ dateString: String) -> String { diff --git a/web/src/server/fwd.ts b/web/src/server/fwd.ts index 1e1b2e86..00806514 100755 --- a/web/src/server/fwd.ts +++ b/web/src/server/fwd.ts @@ -146,7 +146,7 @@ export async function startVibeTunnelForward(args: string[]) { // Validate session ID format for security if (!/^[a-zA-Z0-9_-]+$/.test(sessionId)) { logger.error( - 'Invalid session ID format. Only alphanumeric characters, hyphens, and underscores are allowed.' + `Invalid session ID format: "${sessionId}". Session IDs must only contain letters, numbers, hyphens (-), and underscores (_).` ); closeLogger(); process.exit(1); diff --git a/web/src/server/pty/pty-manager.ts b/web/src/server/pty/pty-manager.ts index b22b8028..0efe3d4f 100644 --- a/web/src/server/pty/pty-manager.ts +++ b/web/src/server/pty/pty-manager.ts @@ -719,7 +719,7 @@ export class PtyManager extends EventEmitter { parser.addData(chunk); for (const { type, payload } of parser.parseMessages()) { - this.handleSocketMessage(session, type, payload, client); + this.handleSocketMessage(session, type, payload); } }); @@ -750,12 +750,7 @@ export class PtyManager extends EventEmitter { /** * Handle incoming socket messages */ - private handleSocketMessage( - session: PtySession, - type: MessageType, - payload: Buffer, - client?: net.Socket - ): void { + private handleSocketMessage(session: PtySession, type: MessageType, payload: Buffer): void { try { const data = parsePayload(type, payload); @@ -778,11 +773,7 @@ export class PtyManager extends EventEmitter { } case MessageType.HEARTBEAT: - // Echo heartbeat back to the client - if (client && !client.destroyed) { - const heartbeatFrame = frameMessage(MessageType.HEARTBEAT, Buffer.alloc(0)); - client.write(heartbeatFrame); - } + // Heartbeat received - no action needed for now break; default: diff --git a/web/src/server/pty/session-manager.ts b/web/src/server/pty/session-manager.ts index 49e4d334..f10269fd 100644 --- a/web/src/server/pty/session-manager.ts +++ b/web/src/server/pty/session-manager.ts @@ -33,7 +33,7 @@ export class SessionManager { private validateSessionId(sessionId: string): void { if (!SessionManager.SESSION_ID_REGEX.test(sessionId)) { throw new PtyError( - 'Invalid session ID format. Only alphanumeric characters, hyphens, and underscores are allowed.', + `Invalid session ID format: "${sessionId}". Session IDs must only contain letters, numbers, hyphens (-), and underscores (_).`, 'INVALID_SESSION_ID' ); } diff --git a/web/src/test/unit/safe-pty-writer.test.ts b/web/src/test/unit/safe-pty-writer.test.ts deleted file mode 100644 index 7fe0d853..00000000 --- a/web/src/test/unit/safe-pty-writer.test.ts +++ /dev/null @@ -1,299 +0,0 @@ -import type { IPty } from 'node-pty'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SafePTYWriter } from '../../server/pty/safe-pty-writer.js'; - -describe('SafePTYWriter', () => { - let mockPty: IPty; - let onDataCallback: ((data: string) => void) | undefined; - let writer: SafePTYWriter; - - beforeEach(() => { - vi.useFakeTimers(); - - // Create mock PTY - mockPty = { - onData: vi.fn((callback: (data: string) => void) => { - onDataCallback = callback; - }), - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - pid: 12345, - process: 'test', - handleFlowControl: false, - onExit: vi.fn(), - pause: vi.fn(), - resume: vi.fn(), - clear: vi.fn(), - } as unknown as IPty; - - writer = new SafePTYWriter(mockPty, { debug: false }); - }); - - afterEach(() => { - vi.clearAllTimers(); - vi.restoreAllMocks(); - }); - - describe('basic functionality', () => { - it('should attach to PTY and intercept data', () => { - const onData = vi.fn(); - writer.attach(onData); - - expect(mockPty.onData).toHaveBeenCalledWith(expect.any(Function)); - - // Simulate PTY output - onDataCallback?.('Hello World'); - expect(onData).toHaveBeenCalledWith('Hello World'); - }); - - it('should queue titles for injection', () => { - writer.queueTitle('Test Title'); - expect(writer.getPendingCount()).toBe(1); - - // New title replaces the previous one - writer.queueTitle('Another Title'); - expect(writer.getPendingCount()).toBe(1); - }); - - it('should clear pending titles', () => { - writer.queueTitle('Test Title'); - // New title replaces the previous one - writer.queueTitle('Another Title'); - expect(writer.getPendingCount()).toBe(1); - - writer.clearPending(); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('safe injection at newlines', () => { - it('should inject title after newline', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Safe Title'); - - // Send data with newline - onDataCallback?.('Hello World\n'); - - expect(onData).toHaveBeenCalledWith('Hello World\n\x1b]0;Safe Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - - it('should only inject latest title when multiple are queued', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Title 1'); - writer.queueTitle('Title 2'); // This replaces Title 1 - - // Send data with newline - onDataCallback?.('Line 1\nLine 2\n'); - - // Should only inject the latest title (Title 2) - expect(onData).toHaveBeenCalledWith('Line 1\n\x1b]0;Title 2\x07Line 2\n'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('safe injection at carriage returns', () => { - it('should inject title after carriage return', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('CR Title'); - - // Send data with carriage return - onDataCallback?.('Progress: 100%\r'); - - expect(onData).toHaveBeenCalledWith('Progress: 100%\r\x1b]0;CR Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('safe injection after escape sequences', () => { - it('should inject after CSI sequence', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('After CSI'); - - // Send data with color escape sequence - onDataCallback?.('\x1b[31mRed Text\x1b[0m'); - - // Should inject after either escape sequence (both are safe) - const callArg = onData.mock.calls[0][0]; - // biome-ignore lint/suspicious/noControlCharactersInRegex: Testing ANSI escape sequences - expect(callArg).toMatch(/\x1b\[31m.*\x1b\]0;After CSI\x07.*Red Text\x1b\[0m/); - expect(writer.getPendingCount()).toBe(0); - }); - - it('should not inject in middle of escape sequence', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Mid Escape'); - - // Send incomplete escape sequence - onDataCallback?.('\x1b[31'); - - // Should not inject yet - expect(onData).toHaveBeenCalledWith('\x1b[31'); - expect(writer.getPendingCount()).toBe(1); // Still pending - - // Complete the sequence - onDataCallback?.('m'); - - // Now it should inject - expect(onData).toHaveBeenCalledWith('m\x1b]0;Mid Escape\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('safe injection at prompt patterns', () => { - it('should inject after bash prompt', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Prompt Title'); - - // Send data with bash prompt - onDataCallback?.('user@host:~$ '); - - expect(onData).toHaveBeenCalledWith('user@host:~$ \x1b]0;Prompt Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - - it('should inject after root prompt', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Root Title'); - - // Send data with root prompt - onDataCallback?.('root@host:/# '); - - expect(onData).toHaveBeenCalledWith('root@host:/# \x1b]0;Root Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('idle injection', () => { - it('should inject during idle period', () => { - const writer = new SafePTYWriter(mockPty, { idleThreshold: 50 }); - const onData = vi.fn(); - writer.attach(onData); - - // Queue title but send data without safe points - writer.queueTitle('Idle Title'); - onDataCallback?.('Some output'); - - // Title should not be injected yet - expect(writer.getPendingCount()).toBe(1); - expect(mockPty.write).not.toHaveBeenCalled(); - - // Advance time past idle threshold - vi.advanceTimersByTime(60); - - // Title should be injected directly to PTY - expect(mockPty.write).toHaveBeenCalledWith('\x1b]0;Idle Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - - it('should reset idle timer on new output', () => { - const writer = new SafePTYWriter(mockPty, { idleThreshold: 50 }); - const onData = vi.fn(); - writer.attach(onData); - - writer.queueTitle('Reset Timer'); - onDataCallback?.('Output 1'); - - // Advance time partially - vi.advanceTimersByTime(30); - - // New output should reset timer - onDataCallback?.('Output 2'); - - // Advance time past original threshold - vi.advanceTimersByTime(30); - - // Should not have injected yet (timer was reset) - expect(mockPty.write).not.toHaveBeenCalled(); - - // Advance remaining time - vi.advanceTimersByTime(30); - - // Now it should inject - expect(mockPty.write).toHaveBeenCalledWith('\x1b]0;Reset Timer\x07'); - }); - }); - - describe('UTF-8 safety', () => { - it('should not inject in middle of UTF-8 sequence', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('UTF8 Title'); - - // Send partial UTF-8 sequence for emoji πŸ˜€ (F0 9F 98 80) - onDataCallback?.('Hello \xF0\x9F'); - - // Should not inject in middle of UTF-8 - expect(onData).toHaveBeenCalledWith('Hello \xF0\x9F'); - expect(writer.getPendingCount()).toBe(1); - - // Complete UTF-8 sequence and add newline - onDataCallback?.('\x98\x80\n'); - - // Should inject after newline - expect(onData).toHaveBeenCalledWith('\x98\x80\n\x1b]0;UTF8 Title\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('force injection', () => { - it('should force inject pending title', () => { - writer.queueTitle('Force 1'); - writer.queueTitle('Force 2'); // This replaces Force 1 - - expect(writer.getPendingCount()).toBe(1); - - writer.forceInject(); - - expect(mockPty.write).toHaveBeenCalledWith('\x1b]0;Force 2\x07'); - expect(writer.getPendingCount()).toBe(0); - }); - }); - - describe('edge cases', () => { - it('should handle empty data', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Empty Data'); - - onDataCallback?.(''); - - expect(onData).toHaveBeenCalledWith(''); - expect(writer.getPendingCount()).toBe(1); - }); - - it('should handle detach', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Detach Test'); - - writer.detach(); - - // Should clear pending titles - expect(writer.getPendingCount()).toBe(0); - }); - - it('should handle multiple safe points in single chunk', () => { - const onData = vi.fn(); - writer.attach(onData); - writer.queueTitle('Multi 1'); - writer.queueTitle('Multi 2'); - writer.queueTitle('Multi 3'); // Only this one will be injected - - // Send data with multiple safe points - onDataCallback?.('Line 1\nLine 2\nLine 3\n'); - - // Only latest title should be injected at first safe point - expect(onData).toHaveBeenCalledWith('Line 1\n\x1b]0;Multi 3\x07Line 2\nLine 3\n'); - expect(writer.getPendingCount()).toBe(0); - }); - }); -}); diff --git a/web/src/test/unit/stream-analyzer.test.ts b/web/src/test/unit/stream-analyzer.test.ts deleted file mode 100644 index fd29cbb5..00000000 --- a/web/src/test/unit/stream-analyzer.test.ts +++ /dev/null @@ -1,274 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { PTYStreamAnalyzer } from '../../server/pty/stream-analyzer.js'; - -describe('PTYStreamAnalyzer', () => { - let analyzer: PTYStreamAnalyzer; - - beforeEach(() => { - analyzer = new PTYStreamAnalyzer(); - }); - - describe('newline detection', () => { - it('should detect newline as safe injection point', () => { - const buffer = Buffer.from('Hello World\n'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0]).toEqual({ - position: 12, // After newline - reason: 'newline', - confidence: 100, - }); - }); - - it('should detect multiple newlines', () => { - const buffer = Buffer.from('Line 1\nLine 2\nLine 3\n'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(3); - expect(points.map((p) => p.position)).toEqual([7, 14, 21]); - expect(points.every((p) => p.reason === 'newline')).toBe(true); - }); - }); - - describe('carriage return detection', () => { - it('should detect carriage return as safe injection point', () => { - const buffer = Buffer.from('Progress: 100%\r'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0]).toEqual({ - position: 15, // After \r - reason: 'carriage_return', - confidence: 90, - }); - }); - - it('should handle CRLF', () => { - const buffer = Buffer.from('Windows line\r\n'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(2); - expect(points[0].reason).toBe('carriage_return'); - expect(points[1].reason).toBe('newline'); - }); - }); - - describe('ANSI escape sequence detection', () => { - it('should detect end of CSI color sequence', () => { - const buffer = Buffer.from('\x1b[31mRed\x1b[0m'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(2); - expect(points[0]).toEqual({ - position: 5, // After \x1b[31m - reason: 'sequence_end', - confidence: 80, - }); - expect(points[1]).toEqual({ - position: 12, // After \x1b[0m - reason: 'sequence_end', - confidence: 80, - }); - }); - - it('should handle cursor movement sequences', () => { - const buffer = Buffer.from('\x1b[2A\x1b[3B'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(2); - expect(points.every((p) => p.reason === 'sequence_end')).toBe(true); - }); - - it('should handle complex CSI sequences', () => { - const buffer = Buffer.from('\x1b[38;5;196m'); // 256 color - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].position).toBe(11); - }); - - it('should not inject in middle of escape sequence', () => { - const buffer1 = Buffer.from('\x1b[31'); - const points1 = analyzer.process(buffer1); - expect(points1).toHaveLength(0); - - const buffer2 = Buffer.from('m'); - const points2 = analyzer.process(buffer2); - expect(points2).toHaveLength(1); - expect(points2[0].position).toBe(1); - }); - }); - - describe('OSC sequence detection', () => { - it('should detect end of OSC title sequence', () => { - const buffer = Buffer.from('\x1b]0;Terminal Title\x07'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0]).toEqual({ - position: 19, // After BEL - reason: 'sequence_end', - confidence: 80, - }); - }); - - it('should handle OSC with ST terminator', () => { - const buffer = Buffer.from('\x1b]0;Title\x1b\\'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].position).toBe(11); // After ESC\ - }); - }); - - describe('prompt pattern detection', () => { - it('should detect bash prompt', () => { - const buffer = Buffer.from('user@host:~$ '); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0]).toEqual({ - position: 13, - reason: 'prompt', - confidence: 85, - }); - }); - - it('should detect root prompt', () => { - const buffer = Buffer.from('root@server:/# '); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].reason).toBe('prompt'); - }); - - it('should detect fish prompt', () => { - const buffer = Buffer.from('~/projects> '); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].reason).toBe('prompt'); - }); - - it('should detect modern prompt', () => { - const buffer = Buffer.from('~/code ❯ '); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].reason).toBe('prompt'); - }); - - it('should detect Python REPL prompt', () => { - const _buffer = Buffer.from('>>> '); - analyzer.process(Buffer.from('>>')); - const points = analyzer.process(Buffer.from('> ')); - - expect(points).toHaveLength(1); - expect(points[0].reason).toBe('prompt'); - }); - }); - - describe('UTF-8 handling', () => { - it('should not inject in middle of 2-byte UTF-8', () => { - // € symbol: C2 A2 - const buffer1 = Buffer.from([0xc2]); - const points1 = analyzer.process(buffer1); - expect(points1).toHaveLength(0); - - const buffer2 = Buffer.from([0xa2, 0x0a]); // Complete char + newline - const points2 = analyzer.process(buffer2); - expect(points2).toHaveLength(1); - expect(points2[0].position).toBe(2); // After newline - }); - - it('should not inject in middle of 3-byte UTF-8', () => { - // βˆ‘ symbol: E2 88 91 - const buffer = Buffer.from([0xe2, 0x88]); - const points = analyzer.process(buffer); - expect(points).toHaveLength(0); - }); - - it('should not inject in middle of 4-byte UTF-8', () => { - // πŸ˜€ emoji: F0 9F 98 80 - const buffer = Buffer.from([0xf0, 0x9f, 0x98]); - const points = analyzer.process(buffer); - expect(points).toHaveLength(0); - }); - - it('should handle complete UTF-8 sequences', () => { - const buffer = Buffer.from('Hello δΈ–η•Œ\n'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(1); - expect(points[0].reason).toBe('newline'); - }); - }); - - describe('state management', () => { - it('should maintain state across multiple process calls', () => { - // Split escape sequence across buffers - const buffer1 = Buffer.from('\x1b['); - const points1 = analyzer.process(buffer1); - expect(points1).toHaveLength(0); - - const buffer2 = Buffer.from('31m'); - const points2 = analyzer.process(buffer2); - expect(points2).toHaveLength(1); - }); - - it('should reset state correctly', () => { - // Process partial sequence - analyzer.process(Buffer.from('\x1b[31')); - - // Reset - analyzer.reset(); - - // Should treat new ESC as start of sequence - const points = analyzer.process(Buffer.from('\x1b[0m')); - expect(points).toHaveLength(1); - }); - - it('should provide state information', () => { - const state1 = analyzer.getState(); - expect(state1.inEscape).toBe(false); - - analyzer.process(Buffer.from('\x1b[')); - const state2 = analyzer.getState(); - expect(state2.inEscape).toBe(true); - }); - }); - - describe('complex scenarios', () => { - it('should handle mixed content', () => { - const buffer = Buffer.from('Normal text\n\x1b[32mGreen\x1b[0m\rProgress\n$ '); - const points = analyzer.process(buffer); - - expect(points.length).toBeGreaterThan(0); - const reasons = points.map((p) => p.reason); - expect(reasons).toContain('newline'); - expect(reasons).toContain('sequence_end'); - expect(reasons).toContain('carriage_return'); - expect(reasons).toContain('prompt'); - }); - - it('should handle rapid color changes', () => { - const buffer = Buffer.from('\x1b[31mR\x1b[32mG\x1b[34mB\x1b[0m'); - const points = analyzer.process(buffer); - - expect(points).toHaveLength(4); // After each sequence - expect(points.every((p) => p.reason === 'sequence_end')).toBe(true); - }); - - it('should handle real terminal output', () => { - // Simulating 'ls --color' output - const buffer = Buffer.from( - '\x1b[0m\x1b[01;34mdir1\x1b[0m\n' + '\x1b[01;32mexecutable\x1b[0m\n' + 'file.txt\n' - ); - const points = analyzer.process(buffer); - - const newlines = points.filter((p) => p.reason === 'newline'); - expect(newlines).toHaveLength(3); - }); - }); -});