This repository has been archived on 2026-03-09. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
oubliette/internal/shell/recorder_test.go
Torjus Håkestad 24c166b86b feat: add session replay with terminal playback via xterm.js
Persist byte-level I/O events from SSH sessions to SQLite and add a web
UI to replay them with original timing. Events are buffered in memory
and flushed every 2s to avoid blocking SSH I/O on database writes.

- Add session_events table (migration 002)
- Add SessionEvent type and storage methods (SQLite + MemoryStore)
- Change RecordingChannel to support multiple callbacks
- Add EventRecorder for buffered event persistence
- Add session detail page with xterm.js terminal replay
- Add /api/sessions/{id}/events JSON endpoint
- Linkify session IDs in dashboard and active sessions
- Vendor xterm.js v5.3.0

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:09:24 +01:00

123 lines
2.7 KiB
Go

package shell
import (
"bytes"
"io"
"sync"
"testing"
"time"
)
// nopCloser wraps a ReadWriter with a no-op Close.
type nopCloser struct {
io.ReadWriter
}
func (nopCloser) Close() error { return nil }
func TestRecordingChannelPassthrough(t *testing.T) {
var buf bytes.Buffer
rc := NewRecordingChannel(nopCloser{&buf})
// Write through the recorder.
msg := []byte("hello")
n, err := rc.Write(msg)
if err != nil {
t.Fatalf("Write: %v", err)
}
if n != len(msg) {
t.Errorf("Write n = %d, want %d", n, len(msg))
}
// Read through the recorder.
out := make([]byte, 16)
n, err = rc.Read(out)
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(out[:n]) != "hello" {
t.Errorf("Read = %q, want %q", out[:n], "hello")
}
if err := rc.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
}
func TestRecordingChannelMultiCallback(t *testing.T) {
var buf bytes.Buffer
rc := NewRecordingChannel(nopCloser{&buf})
type event struct {
ts time.Time
direction int
data string
}
var mu sync.Mutex
var events1, events2 []event
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
mu.Lock()
defer mu.Unlock()
events1 = append(events1, event{ts, direction, string(data)})
})
rc.AddCallback(func(ts time.Time, direction int, data []byte) {
mu.Lock()
defer mu.Unlock()
events2 = append(events2, event{ts, direction, string(data)})
})
// Write triggers both callbacks with direction=1.
rc.Write([]byte("hello"))
// Read triggers both callbacks with direction=0.
out := make([]byte, 16)
rc.Read(out)
mu.Lock()
defer mu.Unlock()
if len(events1) != 2 {
t.Fatalf("callback1 got %d events, want 2", len(events1))
}
if len(events2) != 2 {
t.Fatalf("callback2 got %d events, want 2", len(events2))
}
// Write event should be direction=1.
if events1[0].direction != 1 {
t.Errorf("write direction = %d, want 1", events1[0].direction)
}
// Read event should be direction=0.
if events1[1].direction != 0 {
t.Errorf("read direction = %d, want 0", events1[1].direction)
}
// Both callbacks should get the same timestamp for a single operation.
if events1[0].ts != events2[0].ts {
t.Error("callbacks should receive the same timestamp")
}
}
func TestRecordingChannelWithCallbackClearsExisting(t *testing.T) {
var buf bytes.Buffer
rc := NewRecordingChannel(nopCloser{&buf})
called1 := false
called2 := false
rc.AddCallback(func(_ time.Time, _ int, _ []byte) { called1 = true })
// WithCallback should clear existing and set new.
rc.WithCallback(func(_ time.Time, _ int, _ []byte) { called2 = true })
rc.Write([]byte("x"))
if called1 {
t.Error("first callback should not be called after WithCallback")
}
if !called2 {
t.Error("second callback should be called")
}
}