From f597f8f99d131d2fd692b3d6bb78176aaf32a9e2 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Sat, 14 Feb 2026 17:06:52 +0000 Subject: [PATCH] test: expand Go fuzzing and raise coverage above 80% - Add comprehensive runtime tests for session, docker watcher/stats/http, and server helpers - Add new fuzz targets: FuzzToIntFromQuery and FuzzHTMLHelpers - Add cmd/webterm main entrypoint test - Expand helper conversion and color-mode coverage tests Validation: - go test ./... - go test -race ./... - All 15 fuzz targets pass with short fuzz runs - go test ./... -coverpkg=./... => 80.9% total statement coverage Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- go/cmd/webterm/main_test.go | 13 + go/terminalstate/tracker_test.go | 24 +- go/webterm/coverage_boost_test.go | 766 +++++++++++++++++++++++++ go/webterm/docker_stats_test.go | 59 ++ go/webterm/server_helpers_fuzz_test.go | 37 ++ 5 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 go/cmd/webterm/main_test.go create mode 100644 go/webterm/coverage_boost_test.go create mode 100644 go/webterm/server_helpers_fuzz_test.go diff --git a/go/cmd/webterm/main_test.go b/go/cmd/webterm/main_test.go new file mode 100644 index 0000000..820bbb9 --- /dev/null +++ b/go/cmd/webterm/main_test.go @@ -0,0 +1,13 @@ +package main + +import ( + "os" + "testing" +) + +func TestMainVersionFlag(t *testing.T) { + orig := os.Args + defer func() { os.Args = orig }() + os.Args = []string{"webterm", "-v"} + main() +} diff --git a/go/terminalstate/tracker_test.go b/go/terminalstate/tracker_test.go index 160622f..373d4f8 100644 --- a/go/terminalstate/tracker_test.go +++ b/go/terminalstate/tracker_test.go @@ -1,6 +1,10 @@ package terminalstate -import "testing" +import ( + "testing" + + "github.com/rcarmo/go-te/pkg/te" +) func TestTrackerSnapshotChangeTracking(t *testing.T) { tracker := NewTracker(10, 3) @@ -52,6 +56,24 @@ func TestTrackerResize(t *testing.T) { } } +func TestColorToStringModes(t *testing.T) { + if got := colorToString(te.Color{Mode: te.ColorDefault}); got != "default" { + t.Fatalf("default color mismatch: %q", got) + } + if got := colorToString(te.Color{Mode: te.ColorANSI16, Index: 1}); got != "red" { + t.Fatalf("ansi16 color mismatch: %q", got) + } + if got := colorToString(te.Color{Mode: te.ColorANSI256, Index: 196}); got != "196" { + t.Fatalf("ansi256 color mismatch: %q", got) + } + if got := colorToString(te.Color{Name: "#AABBCC"}); got != "aabbcc" { + t.Fatalf("named hex color mismatch: %q", got) + } + if got := colorToString(te.Color{Name: "Blue"}); got != "blue" { + t.Fatalf("named color mismatch: %q", got) + } +} + func FuzzTrackerFeed(f *testing.F) { f.Add([]byte("hello world")) f.Add([]byte("\x1b[31;1mRed Bold\x1b[0m")) diff --git a/go/webterm/coverage_boost_test.go b/go/webterm/coverage_boost_test.go new file mode 100644 index 0000000..b783918 --- /dev/null +++ b/go/webterm/coverage_boost_test.go @@ -0,0 +1,766 @@ +package webterm + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "reflect" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/rcarmo/webterm-go-port/terminalstate" +) + +func newUnixHTTPTestServer(t *testing.T, handler http.Handler) (string, func()) { + t.Helper() + socket := filepath.Join(t.TempDir(), "docker.sock") + ln, err := net.Listen("unix", socket) + if err != nil { + t.Fatalf("listen unix socket: %v", err) + } + srv := &http.Server{Handler: handler} + go func() { _ = srv.Serve(ln) }() + cleanup := func() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = srv.Shutdown(ctx) + _ = ln.Close() + _ = os.Remove(socket) + } + return socket, cleanup +} + +func TestDockerSocketPathVariants(t *testing.T) { + t.Setenv(DockerHostEnv, "") + if got := DockerSocketPath(); got != defaultDockerSocket { + t.Fatalf("empty %s: got %q", DockerHostEnv, got) + } + + t.Setenv(DockerHostEnv, "unix:///tmp/docker.sock") + if got := DockerSocketPath(); got != "/tmp/docker.sock" { + t.Fatalf("unix:// host: got %q", got) + } + + t.Setenv(DockerHostEnv, "/tmp/direct.sock") + if got := DockerSocketPath(); got != "/tmp/direct.sock" { + t.Fatalf("absolute host: got %q", got) + } + + t.Setenv(DockerHostEnv, "tcp://127.0.0.1:2375") + if got := DockerSocketPath(); got != defaultDockerSocket { + t.Fatalf("unsupported host should fallback: got %q", got) + } +} + +func TestUnixJSONRequestAndSharedClient(t *testing.T) { + sharedClientsMu.Lock() + sharedClients = map[string]*http.Client{} + sharedClientsMu.Unlock() + + handler := http.NewServeMux() + handler.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "method": r.Method}) + }) + handler.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) { + var payload map[string]any + _ = json.NewDecoder(r.Body).Decode(&payload) + _ = json.NewEncoder(w).Encode(payload) + }) + socket, cleanup := newUnixHTTPTestServer(t, handler) + defer cleanup() + + status, body, err := unixJSONRequest(socket, http.MethodGet, "/ping", nil) + if err != nil || status != http.StatusOK { + t.Fatalf("unixJSONRequest GET: status=%d err=%v", status, err) + } + if !strings.Contains(string(body), `"ok":true`) { + t.Fatalf("unexpected GET body: %s", string(body)) + } + + status, body, err = unixJSONRequest(socket, http.MethodPost, "/echo", map[string]any{"x": 1}) + if err != nil || status != http.StatusOK { + t.Fatalf("unixJSONRequest POST: status=%d err=%v", status, err) + } + if !strings.Contains(string(body), `"x":1`) { + t.Fatalf("unexpected POST body: %s", string(body)) + } + + c1 := sharedUnixClient(socket) + c2 := sharedUnixClient(socket) + if c1 != c2 { + t.Fatalf("sharedUnixClient should cache by socket path") + } +} + +func TestNoopConnectorMethods(t *testing.T) { + var c noopConnector + c.OnData([]byte("x")) + c.OnBinary([]byte("y")) + c.OnMeta(map[string]any{"k": "v"}) + c.OnClose() +} + +func TestSessionManagerAPIsAndClosePaths(t *testing.T) { + manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}) + manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() }) + + apps := manager.Apps() + if len(apps) != 1 || apps[0].Slug != "shell" { + t.Fatalf("unexpected apps: %+v", apps) + } + if app, ok := manager.GetDefaultApp(); !ok || app.Slug != "shell" { + t.Fatalf("GetDefaultApp failed: app=%+v ok=%v", app, ok) + } + + session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24) + if err != nil || session == nil { + t.Fatalf("NewSession failed: %v", err) + } + route, running, ok := manager.GetFirstRunningSession() + if !ok || route != "route-1" || running == nil { + t.Fatalf("GetFirstRunningSession failed: route=%q ok=%v", route, ok) + } + + manager.CloseSession("sid-1") + if manager.GetSessionByRouteKey("route-1") != nil { + t.Fatalf("CloseSession should remove route mapping") + } + + _, _ = manager.NewSession("shell", "sid-2", "route-2", 80, 24) + _, _ = manager.NewSession("shell", "sid-3", "route-3", 80, 24) + manager.CloseAll() + if s := manager.GetSession("sid-2"); s != nil && s.IsRunning() { + t.Fatalf("CloseAll should stop session sid-2") + } +} + +func TestLocalClientConnectorAndHelpers(t *testing.T) { + server := NewLocalServer(Config{}, ServerOptions{}) + connector := &localClientConnector{server: server, sessionID: "sid", routeKey: "rk"} + connector.OnData([]byte("abc")) + connector.OnBinary([]byte("def")) + connector.OnMeta(map[string]any{"x": 1}) + connector.OnClose() + + if got := toIntFromQuery("42", 7); got != 42 { + t.Fatalf("toIntFromQuery valid: got %d", got) + } + if got := toIntFromQuery("not-a-number", 7); got != 7 { + t.Fatalf("toIntFromQuery fallback: got %d", got) + } +} + +func TestHandleEventsDisconnect(t *testing.T) { + server := NewLocalServer(Config{}, ServerOptions{}) + ctx, cancel := context.WithCancel(context.Background()) + req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx) + w := httptest.NewRecorder() + + done := make(chan struct{}) + go func() { + server.handleEvents(w, req) + close(done) + }() + time.Sleep(20 * time.Millisecond) + cancel() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("handleEvents did not exit on context cancellation") + } +} + +func TestRunWithCanceledContext(t *testing.T) { + server := NewLocalServer(Config{}, ServerOptions{Host: "127.0.0.1", Port: 0}) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := server.Run(ctx); err != nil { + t.Fatalf("Run with canceled context failed: %v", err) + } +} + +func TestDockerWatcherHelpersAndContainerLifecycle(t *testing.T) { + manager := NewSessionManager(nil) + manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() }) + + var addedCount atomic.Int32 + var removedCount atomic.Int32 + watcher := NewDockerWatcher( + manager, + "/tmp/does-not-exist.sock", + func(slug, name, command string) { addedCount.Add(1) }, + func(slug string) { removedCount.Add(1) }, + ) + + if !hasWebtermLabel(map[string]string{WebtermLabelName: "auto"}) { + t.Fatalf("expected command label to match") + } + if !hasWebtermLabel(map[string]string{WebtermThemeLabel: "nord"}) { + t.Fatalf("expected theme label to match") + } + if hasWebtermLabel(map[string]string{"other": "x"}) { + t.Fatalf("unexpected label match") + } + if !isAutoLabel("") || !isAutoLabel("auto") || !isAutoLabel(" AUTO ") { + t.Fatalf("expected auto labels") + } + if isAutoLabel("bash") { + t.Fatalf("non-auto label incorrectly matched") + } + + container := map[string]any{ + "Id": "123456789012", + "Names": []any{"/my_container.1"}, + "Labels": map[string]any{ + WebtermLabelName: "auto", + WebtermThemeLabel: "nord", + }, + } + watcher.addContainer(container) + watcher.addContainer(container) // duplicate should be ignored + + slug := watcher.containerToSlug(container) + if app, ok := manager.AppBySlug(slug); !ok || app.Theme != "nord" || app.Command != AutoCommandSentinel { + t.Fatalf("added app mismatch: app=%+v ok=%v", app, ok) + } + if addedCount.Load() != 1 { + t.Fatalf("expected exactly one add callback, got %d", addedCount.Load()) + } + + manager.sessions["sid"] = newFakeSession() + _ = manager.routes.Set(slug, "sid") + watcher.removeContainer("1234567") + if _, ok := manager.AppBySlug(slug); ok { + t.Fatalf("removeContainer should remove app %q", slug) + } + if removedCount.Load() != 1 { + t.Fatalf("expected remove callback") + } +} + +func TestDockerWatcherListAndHandleEvent(t *testing.T) { + manager := NewSessionManager(nil) + watcher := NewDockerWatcher(manager, "", nil, nil) + + handler := http.NewServeMux() + handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) { + containers := []map[string]any{ + { + "Id": "abc123", + "Names": []any{"/svc1"}, + "Labels": map[string]any{ + WebtermLabelName: "auto", + }, + }, + } + _ = json.NewEncoder(w).Encode(containers) + }) + handler.HandleFunc("/containers/evt123/json", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "Name": "/evt_container", + "Config": map[string]any{ + "Labels": map[string]any{ + WebtermLabelName: "auto", + }, + }, + }) + }) + socket, cleanup := newUnixHTTPTestServer(t, handler) + defer cleanup() + watcher.socketPath = socket + + containers, err := watcher.listLabeledContainers() + if err != nil { + t.Fatalf("listLabeledContainers error: %v", err) + } + if len(containers) != 1 || asString(containers[0]["Id"]) != "abc123" { + t.Fatalf("unexpected containers payload: %+v", containers) + } + + watcher.handleEvent(map[string]any{ + "Action": "start", + "Actor": map[string]any{"ID": "evt123"}, + }) + if _, ok := manager.AppBySlug("evt-container"); !ok { + t.Fatalf("start event should add app") + } + watcher.handleEvent(map[string]any{ + "Action": "die", + "Actor": map[string]any{"ID": "evt123"}, + }) + if _, ok := manager.AppBySlug("evt-container"); ok { + t.Fatalf("die event should remove app") + } +} + +func TestDockerStatsCollectorLifecycleAndPolling(t *testing.T) { + handler := http.NewServeMux() + handler.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "Id": "deadbeefcafebabe", + "Names": []any{"/svc"}, + "Labels": map[string]any{ + "com.docker.compose.project": "proj", + "com.docker.compose.service": "svc", + }, + }, + }) + }) + handler.HandleFunc("/containers/deadbeefcafe/stats", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "cpu_stats": map[string]any{ + "cpu_usage": map[string]any{"total_usage": 200.0, "percpu_usage": []any{1.0, 2.0}}, + "system_cpu_usage": 400.0, + "online_cpus": 2.0, + }, + "precpu_stats": map[string]any{ + "cpu_usage": map[string]any{"total_usage": 100.0}, + "system_cpu_usage": 200.0, + }, + }) + }) + socket, cleanup := newUnixHTTPTestServer(t, handler) + defer cleanup() + + collector := NewDockerStatsCollector(socket, "proj") + if !collector.Available() { + t.Fatalf("collector should be available against test unix socket") + } + + collector.AddService("svc") + collector.AddService("svc") // no duplicate + collector.AddService("other") + collector.RemoveService("other") + if got := collector.serviceList; !reflect.DeepEqual(got, []string{"svc"}) { + t.Fatalf("unexpected service list: %+v", got) + } + + mapping := collector.discoverContainers([]string{"svc"}) + if mapping["svc"] != "deadbeefcafe" { + t.Fatalf("unexpected mapping: %+v", mapping) + } + + collector.pollContainer("svc", "deadbeefcafe") + history := collector.GetCPUHistory("svc") + if len(history) != 1 || history[0] <= 0 { + t.Fatalf("expected one positive CPU sample, got %+v", history) + } + + collector.Start([]string{"svc"}) + time.Sleep(20 * time.Millisecond) + collector.Stop() +} + +type recorderConnector struct { + mu sync.Mutex + data [][]byte + closed bool +} + +func (r *recorderConnector) OnData(data []byte) { + r.mu.Lock() + defer r.mu.Unlock() + r.data = append(r.data, append([]byte{}, data...)) +} + +func (r *recorderConnector) OnBinary(payload []byte) { r.OnData(payload) } +func (r *recorderConnector) OnMeta(meta map[string]any) {} +func (r *recorderConnector) OnClose() { + r.mu.Lock() + defer r.mu.Unlock() + r.closed = true +} + +func TestTerminalSessionMethodsAndReadLoop(t *testing.T) { + s := NewTerminalSession("sid", "unterminated '") + if err := s.Open(80, 24); err == nil { + t.Fatalf("expected shlex parse error") + } + if err := s.Start(&recorderConnector{}); err == nil { + t.Fatalf("expected Start error when session is not open") + } + + conn := &recorderConnector{} + s.UpdateConnector(conn) + s.tracker = terminalstate.NewTracker(80, 24) + s.handleOutput([]byte("abc")) + if got := string(s.GetReplayBuffer()); got != "abc" { + t.Fatalf("unexpected replay buffer: %q", got) + } + + pipeR, pipeW, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer pipeR.Close() + defer pipeW.Close() + s.ptyFile = pipeW + if ok := s.SendBytes([]byte("x")); !ok { + t.Fatalf("SendBytes should succeed with writable ptyFile") + } + buf := make([]byte, 1) + if _, err := pipeR.Read(buf); err != nil || string(buf) != "x" { + t.Fatalf("pipe read failed: %v %q", err, string(buf)) + } + + s.ptyFile = nil + if err := s.SetTerminalSize(80, 24); err == nil { + t.Fatalf("expected SetTerminalSize error when closed") + } + if err := s.ForceRedraw(); err == nil { + t.Fatalf("expected ForceRedraw error when closed") + } + if !s.SendMeta(map[string]any{"k": "v"}) { + t.Fatalf("SendMeta should return true") + } + if s.IsRunning() { + t.Fatalf("new session should not be running") + } + + s.width, s.height = 10, 2 + s.tracker = nil + snapshot := s.GetScreenSnapshot() + if snapshot.Width != 10 || snapshot.Height != 2 { + t.Fatalf("unexpected snapshot dimensions: %dx%d", snapshot.Width, snapshot.Height) + } + + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("pipe: %v", err) + } + defer reader.Close() + s2 := NewTerminalSession("sid2", "/bin/sh") + rc := &recorderConnector{} + s2.connector = rc + s2.tracker = terminalstate.NewTracker(80, 24) + s2.running = true + go s2.readLoop(reader) + _, _ = writer.Write([]byte("hello")) + _ = writer.Close() + if err := s2.Wait(); err != nil { + t.Fatalf("unexpected wait error: %v", err) + } + rc.mu.Lock() + defer rc.mu.Unlock() + if !rc.closed || len(rc.data) == 0 { + t.Fatalf("expected readLoop to forward data and close connector") + } + + cmd := exec.Command("true") + s3 := NewTerminalSession("sid3", "/bin/sh") + s3.cmd = cmd + _ = s3.Close() +} + +func TestDockerExecSessionMethodsAndAPI(t *testing.T) { + spec := DockerExecSpec{Container: "my/container", Command: []string{"sh", "-lc", "echo hi"}, User: "root"} + s := NewDockerExecSession("sid", spec, "/tmp/none.sock") + + if err := s.Start(&recorderConnector{}); err == nil { + t.Fatalf("expected Start error when not open") + } + if s.SendBytes([]byte("x")) { + t.Fatalf("SendBytes should fail when conn is nil") + } + if !s.SendMeta(map[string]any{"k": "v"}) { + t.Fatalf("SendMeta should return true") + } + if s.IsRunning() { + t.Fatalf("new DockerExecSession should not be running") + } + + s.tracker = terminalstate.NewTracker(80, 24) + conn := &recorderConnector{} + s.UpdateConnector(conn) + s.handleOutput([]byte("abc")) + if got := string(s.GetReplayBuffer()); got != "abc" { + t.Fatalf("unexpected replay: %q", got) + } + + c1, c2 := net.Pipe() + defer c2.Close() + s.conn = c1 + readCh := make(chan []byte, 1) + go func() { + buf := make([]byte, 1) + if _, err := io.ReadFull(c2, buf); err != nil { + readCh <- nil + return + } + readCh <- buf + }() + if !s.SendBytes([]byte("z")) { + t.Fatalf("SendBytes should succeed with active conn") + } + select { + case read := <-readCh: + if string(read) != "z" { + t.Fatalf("pipe read mismatch: %q", string(read)) + } + case <-time.After(2 * time.Second): + t.Fatalf("timed out waiting for net.Pipe read") + } + + s.execID = "" + if err := s.SetTerminalSize(100, 40); err != nil { + t.Fatalf("SetTerminalSize with empty execID should not error: %v", err) + } + if err := s.ForceRedraw(); err != nil { + t.Fatalf("ForceRedraw failed: %v", err) + } + + s.width, s.height = 10, 2 + s.tracker = nil + snap := s.GetScreenSnapshot() + if snap.Width != 10 || snap.Height != 2 { + t.Fatalf("unexpected snapshot dimensions: %dx%d", snap.Width, snap.Height) + } + + r1, r2 := net.Pipe() + defer r2.Close() + s2 := NewDockerExecSession("sid2", spec, "/tmp/none.sock") + rc := &recorderConnector{} + s2.connector = rc + s2.tracker = terminalstate.NewTracker(80, 24) + s2.running = true + go s2.readLoop(r1) + _, _ = r2.Write([]byte("hello")) + _ = r2.Close() + if err := s2.Wait(); err != nil { + t.Fatalf("unexpected wait error: %v", err) + } + rc.mu.Lock() + defer rc.mu.Unlock() + if !rc.closed || len(rc.data) == 0 { + t.Fatalf("expected readLoop to forward data and close connector") + } + + // API-level tests for createExec/startExecSocket/resizeExec + mux := http.NewServeMux() + mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/exec") { + http.NotFound(w, r) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec123"}) + }) + mux.HandleFunc("/exec/exec123/resize", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = io.WriteString(w, "ok") + }) + mux.HandleFunc("/exec/exec123/start", func(w http.ResponseWriter, r *http.Request) { + hj, ok := w.(http.Hijacker) + if !ok { + t.Fatalf("response writer is not a hijacker") + } + conn, rw, err := hj.Hijack() + if err != nil { + t.Fatalf("hijack failed: %v", err) + } + _, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + _, _ = rw.WriteString("ok") + _ = rw.Flush() + _ = conn.Close() + }) + socket, cleanup := newUnixHTTPTestServer(t, mux) + defer cleanup() + + s3 := NewDockerExecSession("sid3", spec, socket) + execID, err := s3.createExec() + if err != nil || execID != "exec123" { + t.Fatalf("createExec failed: id=%q err=%v", execID, err) + } + c, err := s3.startExecSocket(execID) + if err != nil { + t.Fatalf("startExecSocket failed: %v", err) + } + defer c.Close() + reader := bufio.NewReader(c) + reply, _ := reader.ReadString('k') + if reply == "" { + t.Fatalf("expected upgraded stream payload") + } + s3.execID = execID + if err := s3.resizeExec(80, 24); err != nil { + t.Fatalf("resizeExec failed: %v", err) + } + _ = s3.Close() +} + +func TestDockerExecSessionOpenAndStart(t *testing.T) { + spec := DockerExecSpec{Container: "c1", Command: []string{"sh"}, User: ""} + + mux := http.NewServeMux() + mux.HandleFunc("/containers/c1/exec", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec-open"}) + }) + mux.HandleFunc("/exec/exec-open/resize", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/exec/exec-open/start", func(w http.ResponseWriter, r *http.Request) { + hj, _ := w.(http.Hijacker) + conn, rw, _ := hj.Hijack() + _, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n") + _, _ = rw.WriteString("hello") + _ = rw.Flush() + _ = conn.Close() + }) + socket, cleanup := newUnixHTTPTestServer(t, mux) + defer cleanup() + + s := NewDockerExecSession("sid-open", spec, socket) + if err := s.Open(0, 0); err != nil { + t.Fatalf("Open failed: %v", err) + } + rc := &recorderConnector{} + if err := s.Start(rc); err != nil { + t.Fatalf("Start failed: %v", err) + } + if err := s.Wait(); err != nil && !errors.Is(err, io.EOF) { + t.Fatalf("Wait failed: %v", err) + } +} + +func TestTerminalSessionOpenStartAndResize(t *testing.T) { + s := NewTerminalSession("sid-term", "/bin/sh -lc 'printf ok'") + if err := s.Open(0, 0); err != nil { + t.Fatalf("Open failed: %v", err) + } + rc := &recorderConnector{} + if err := s.Start(rc); err != nil { + t.Fatalf("Start failed: %v", err) + } + if err := s.SetTerminalSize(100, 30); err != nil { + t.Fatalf("SetTerminalSize failed: %v", err) + } + if err := s.Wait(); err != nil { + t.Fatalf("Wait failed: %v", err) + } + _ = s.Close() +} + +func TestTerminalSessionOpenWhenRunningNoop(t *testing.T) { + s := NewTerminalSession("sid-running", "/bin/true") + s.running = true + if err := s.Open(80, 24); err != nil { + t.Fatalf("Open should be a no-op when already running: %v", err) + } +} + +func TestSessionManagerDefaultSessionFactory(t *testing.T) { + manager := NewSessionManager(nil) + t.Setenv(DockerAutoCommandEnv, "tmux new-session -ADs {container}") + t.Setenv(DockerUsernameEnv, "alice") + + auto := manager.defaultSessionFactory(App{Name: "svc1", Command: AutoCommandSentinel}, "sid") + execSession, ok := auto.(*DockerExecSession) + if !ok { + t.Fatalf("expected DockerExecSession, got %T", auto) + } + if len(execSession.spec.Command) == 0 || execSession.spec.Command[len(execSession.spec.Command)-1] != "svc1" { + t.Fatalf("expected container placeholder expansion, got %+v", execSession.spec.Command) + } + if execSession.spec.User != "alice" { + t.Fatalf("expected docker user from env, got %q", execSession.spec.User) + } + + plain := manager.defaultSessionFactory(App{Name: "term", Command: "/bin/sh"}, "sid2") + if _, ok := plain.(*TerminalSession); !ok { + t.Fatalf("expected TerminalSession, got %T", plain) + } +} + +func TestDefaultConfigAndCPUSparklineEndpoint(t *testing.T) { + if cfg := DefaultConfig(); len(cfg.Apps) != 0 { + t.Fatalf("DefaultConfig expected empty apps") + } + + mux := http.NewServeMux() + mux.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte("OK")) + }) + mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]map[string]any{ + { + "Id": "cafebabedeadbeef", + "Names": []any{"/svc"}, + "Labels": map[string]any{ + "com.docker.compose.project": "proj", + "com.docker.compose.service": "svc", + }, + }, + }) + }) + mux.HandleFunc("/containers/cafebabedead/stats", func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "cpu_stats": map[string]any{ + "cpu_usage": map[string]any{"total_usage": 300.0, "percpu_usage": []any{1.0, 2.0}}, + "system_cpu_usage": 600.0, + "online_cpus": 2.0, + }, + "precpu_stats": map[string]any{ + "cpu_usage": map[string]any{"total_usage": 150.0}, + "system_cpu_usage": 300.0, + }, + }) + }) + socket, cleanup := newUnixHTTPTestServer(t, mux) + defer cleanup() + t.Setenv(DockerHostEnv, "unix://"+socket) + + server := NewLocalServer( + Config{}, + ServerOptions{ + ComposeMode: true, + ComposeProject: "proj", + LandingApps: []App{{Name: "svc", Slug: "svc", Command: "/bin/sh", Terminal: true}}, + }, + ) + server.setupDockerFeatures() + defer server.shutdown() + + req := httptest.NewRequest(http.MethodGet, "/cpu-sparkline.svg?container=svc&width=x&height=y", nil) + rr := httptest.NewRecorder() + server.handleCPUSparkline(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", rr.Code) + } + if !strings.Contains(rr.Body.String(), "alert("x")`) + f.Add(`a&bd"e`) + f.Add(``) + + f.Fuzz(func(t *testing.T, value string) { + escaped := htmlEscape(value) + attrEscaped := htmlAttrEscape(value) + if len(escaped) == 0 && len(value) > 0 { + t.Fatalf("htmlEscape unexpectedly empty for %q", value) + } + if len(attrEscaped) == 0 && len(value) > 0 { + t.Fatalf("htmlAttrEscape unexpectedly empty for %q", value) + } + }) +}