logwriter_test.go RawHistoryFolder

1package logwriter
2
3import (
4	"bytes"
5	"context"
6	"io"
7	"os"
8	"path/filepath"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/require"
15)
16
17func TestNew(t *testing.T) {
18	t.Run("creates log directory", func(t *testing.T) {
19		dir := t.TempDir()
20		logDir := filepath.Join(dir, "containers")
21
22		writer, err := New(Config{
23			Dir:        logDir,
24			MaxSize:    100,
25			MaxBackups: 3,
26			MaxAge:     28,
27		})
28
29		require.NoError(t, err)
30		require.NotNil(t, writer)
31		defer writer.Close()
32
33		// Verify directory was created
34		info, err := os.Stat(logDir)
35		require.NoError(t, err)
36		assert.True(t, info.IsDir())
37	})
38
39	t.Run("uses existing directory", func(t *testing.T) {
40		logDir := t.TempDir()
41
42		writer, err := New(Config{
43			Dir:        logDir,
44			MaxSize:    100,
45			MaxBackups: 3,
46			MaxAge:     28,
47		})
48
49		require.NoError(t, err)
50		require.NotNil(t, writer)
51		defer writer.Close()
52	})
53}
54
55func TestStartLogging(t *testing.T) {
56	t.Run("creates log file and writes data", func(t *testing.T) {
57		logDir := t.TempDir()
58		writer, err := New(Config{
59			Dir:        logDir,
60			MaxSize:    100,
61			MaxBackups: 3,
62			MaxAge:     28,
63		})
64		require.NoError(t, err)
65		defer writer.Close()
66
67		// Create a mock log stream with Docker multiplex format
68		// Format: 1 byte stream type, 3 bytes reserved, 4 bytes size (big endian), then data
69		logData := createDockerLogEntry(1, "Hello from container\n")
70		logStream := io.NopCloser(bytes.NewReader(logData))
71
72		err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream)
73		require.NoError(t, err)
74
75		// Wait for the goroutine to process
76		time.Sleep(100 * time.Millisecond)
77
78		// Verify log file was created
79		logFile := filepath.Join(logDir, "app_example_com.log")
80		content, err := os.ReadFile(logFile)
81		require.NoError(t, err)
82		assert.Contains(t, string(content), "Hello from container")
83	})
84
85	t.Run("replaces existing log stream for same container", func(t *testing.T) {
86		logDir := t.TempDir()
87		writer, err := New(Config{
88			Dir:        logDir,
89			MaxSize:    100,
90			MaxBackups: 3,
91			MaxAge:     28,
92		})
93		require.NoError(t, err)
94		defer writer.Close()
95
96		// Start first logging
97		logData1 := createDockerLogEntry(1, "First log\n")
98		logStream1 := io.NopCloser(bytes.NewReader(logData1))
99		err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream1)
100		require.NoError(t, err)
101
102		time.Sleep(50 * time.Millisecond)
103
104		// Start second logging for same container
105		logData2 := createDockerLogEntry(1, "Second log\n")
106		logStream2 := io.NopCloser(bytes.NewReader(logData2))
107		err = writer.StartLogging(context.Background(), "container-123", "app.example.com", logStream2)
108		require.NoError(t, err)
109
110		time.Sleep(100 * time.Millisecond)
111
112		// Should have both logs (file is appended)
113		logFile := filepath.Join(logDir, "app_example_com.log")
114		content, err := os.ReadFile(logFile)
115		require.NoError(t, err)
116		assert.Contains(t, string(content), "Second log")
117	})
118}
119
120func TestStopLogging(t *testing.T) {
121	t.Run("stops log collection", func(t *testing.T) {
122		logDir := t.TempDir()
123		writer, err := New(Config{
124			Dir:        logDir,
125			MaxSize:    100,
126			MaxBackups: 3,
127			MaxAge:     28,
128		})
129		require.NoError(t, err)
130		defer writer.Close()
131
132		// Use a pipe so we can control the stream
133		pr, pw := io.Pipe()
134		err = writer.StartLogging(context.Background(), "container-123", "app.example.com", pr)
135		require.NoError(t, err)
136
137		// Write some data
138		go func() {
139			pw.Write(createDockerLogEntry(1, "Test log\n"))
140			// Keep pipe open until stopped
141		}()
142
143		time.Sleep(50 * time.Millisecond)
144
145		// Stop logging
146		err = writer.StopLogging("container-123")
147		require.NoError(t, err)
148
149		// Clean up pipe
150		pw.Close()
151	})
152
153	t.Run("noop for non-existent container", func(t *testing.T) {
154		logDir := t.TempDir()
155		writer, err := New(Config{
156			Dir:        logDir,
157			MaxSize:    100,
158			MaxBackups: 3,
159			MaxAge:     28,
160		})
161		require.NoError(t, err)
162		defer writer.Close()
163
164		// Should not error
165		err = writer.StopLogging("non-existent")
166		require.NoError(t, err)
167	})
168}
169
170func TestClose(t *testing.T) {
171	t.Run("stops all log streams", func(t *testing.T) {
172		logDir := t.TempDir()
173		writer, err := New(Config{
174			Dir:        logDir,
175			MaxSize:    100,
176			MaxBackups: 3,
177			MaxAge:     28,
178		})
179		require.NoError(t, err)
180
181		// Start multiple log streams
182		for i := 0; i < 3; i++ {
183			pr, pw := io.Pipe()
184			containerID := "container-" + string(rune('a'+i))
185			domain := "app" + string(rune('0'+i)) + ".example.com"
186			err := writer.StartLogging(context.Background(), containerID, domain, pr)
187			require.NoError(t, err)
188
189			// Keep pipe alive briefly
190			go func(pw *io.PipeWriter) {
191				time.Sleep(50 * time.Millisecond)
192				pw.Close()
193			}(pw)
194		}
195
196		time.Sleep(25 * time.Millisecond)
197
198		// Close should stop all
199		err = writer.Close()
200		require.NoError(t, err)
201
202		// Verify all streams are gone
203		assert.Empty(t, writer.streams)
204	})
205}
206
207func TestSanitizeDomain(t *testing.T) {
208	tests := []struct {
209		input    string
210		expected string
211	}{
212		{"app.example.com", "app_example_com"},
213		{"api.v1.example.com", "api_v1_example_com"},
214		{"localhost:8080", "localhost_8080"},
215		{"app/service", "app_service"},
216		{"app with spaces", "app_with_spaces"},
217		{"simple", "simple"},
218	}
219
220	for _, tc := range tests {
221		t.Run(tc.input, func(t *testing.T) {
222			result := sanitizeDomain(tc.input)
223			assert.Equal(t, tc.expected, result)
224		})
225	}
226}
227
228func TestMultipleContainers(t *testing.T) {
229	logDir := t.TempDir()
230	writer, err := New(Config{
231		Dir:        logDir,
232		MaxSize:    100,
233		MaxBackups: 3,
234		MaxAge:     28,
235	})
236	require.NoError(t, err)
237	defer writer.Close()
238
239	// Start logging for multiple containers
240	containers := []struct {
241		id     string
242		domain string
243		msg    string
244	}{
245		{"container-1", "app1.example.com", "Log from app1"},
246		{"container-2", "app2.example.com", "Log from app2"},
247		{"container-3", "api.example.com", "Log from api"},
248	}
249
250	for _, c := range containers {
251		logData := createDockerLogEntry(1, c.msg+"\n")
252		logStream := io.NopCloser(bytes.NewReader(logData))
253		err := writer.StartLogging(context.Background(), c.id, c.domain, logStream)
254		require.NoError(t, err)
255	}
256
257	// Wait for processing
258	time.Sleep(100 * time.Millisecond)
259
260	// Verify each container has its own log file
261	for _, c := range containers {
262		filename := strings.ReplaceAll(c.domain, ".", "_") + ".log"
263		logFile := filepath.Join(logDir, filename)
264		content, err := os.ReadFile(logFile)
265		require.NoError(t, err, "Log file should exist for %s", c.domain)
266		assert.Contains(t, string(content), c.msg)
267	}
268}
269
270// createDockerLogEntry creates a Docker multiplexed log entry.
271// streamType: 0=stdin, 1=stdout, 2=stderr
272func createDockerLogEntry(streamType byte, data string) []byte {
273	size := len(data)
274	header := make([]byte, 8)
275	header[0] = streamType
276	// bytes 1-3 are reserved (zeros)
277	header[4] = byte(size >> 24)
278	header[5] = byte(size >> 16)
279	header[6] = byte(size >> 8)
280	header[7] = byte(size)
281
282	return append(header, []byte(data)...)
283}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close