package console import ( "bytes" "strings" "testing" "time" "github.com/carlosframework/platform/internal/applog" ) // TestLogLinesMatchTheCLIAndAlign is the Logs tab's whole promise in one // test: the console renders the SAME line `carlos logs` prints (logs.go's // printLogRecords — UTC time to the second, [stream], host, two spaces, // message, then sorted k=v fields), and it column-aligns that line by // padding stream and host to the widest in the window, the way a terminal // log tool does. A record's level becomes a tone the pane can colour, never // text — the CLI doesn't print the word "error" either. func TestLogLinesMatchTheCLIAndAlign(t *testing.T) { ts := time.Date(2026, 8, 16, 13, 49, 38, 848000000, time.UTC) got := logLines([]applog.Record{ {TS: ts, Stream: "app", Host: "test1.bdk.oncarlos.com", Msg: "database ready"}, {TS: ts.Add(time.Second), Stream: "platform", Host: "a.oncarlos.com", Msg: "instance woke"}, {TS: ts.Add(2 * time.Second), Stream: "app", Host: "a.oncarlos.com", Msg: "boom", Level: applog.LevelError, Fields: map[string]string{"status": "500", "ms": "3"}}, }) if len(got) != 3 { t.Fatalf("logLines returned %d lines, want 3", len(got)) } // Line one sets the column widths: "[platform]" is the widest tag in // this window and "test1.bdk.oncarlos.com" the widest host, so every // line pads to those, including the ones that need no padding. want := []logLine{ { Time: "2026-08-16 13:49:38", Stream: "[app] ", Host: "test1.bdk.oncarlos.com", Msg: "database ready", }, { Time: "2026-08-16 13:49:39", Stream: "[platform]", Host: "a.oncarlos.com ", Msg: "instance woke", }, { Time: "2026-08-16 13:49:40", Stream: "[app] ", Host: "a.oncarlos.com ", Msg: "boom", Fields: " ms=3 status=500", Tone: "error", }, } for i := range want { if got[i] != want[i] { t.Fatalf("line %d = %#v, want %#v", i, got[i], want[i]) } } } // TestLogLinesToneAndEmptyColumns covers the rest of the fold: warn is its // own tone, an unknown/absent level has none, and a window whose records // carry no host at all renders no host column rather than a run of padding // spaces before every message. func TestLogLinesToneAndEmptyColumns(t *testing.T) { ts := time.Date(2026, 8, 16, 13, 0, 0, 0, time.UTC) got := logLines([]applog.Record{ {TS: ts, Stream: "platform", Msg: "slow wake", Level: applog.LevelWarn}, {TS: ts, Stream: "platform", Msg: "woke", Level: applog.LevelInfo}, {TS: ts, Stream: "platform", Msg: "unlevelled"}, }) if got[0].Tone != "warn" { t.Fatalf("warn record tone = %q, want %q", got[0].Tone, "warn") } for _, i := range []int{1, 2} { if got[i].Tone != "" { t.Fatalf("line %d tone = %q, want none (only warn/error colour a line)", i, got[i].Tone) } } for i, l := range got { if l.Host != "" { t.Fatalf("line %d host = %q, want empty — no record in this window has one", i, l.Host) } } } // TestAppLogsPageIsATerminalPane renders the Logs tab and asserts the shape // the tab now has: a monospaced pane of whole lines, NOT the four-column // table it used to be (which wrapped every message across three rows), and // no header row, because a terminal has none. A message is app output — // arbitrary bytes from someone else's process — so the escaping is asserted // here too, on the page that prints it verbatim. func TestAppLogsPageIsATerminalPane(t *testing.T) { ts := time.Date(2026, 8, 16, 13, 49, 38, 0, time.UTC) p := page{ Title: "Logs", CSRF: "tok", AppName: "taskmonger", ActiveTab: "logs", Account: &Account{Name: "tom@keymail.dev", Sqid: "bdk"}, Nav: "/a/bdk", LogSince: "1h", LogRows: []applog.Record{ {TS: ts, Stream: "app", Host: "test1.bdk.oncarlos.com", Msg: `hi `}, {TS: ts.Add(time.Second), Stream: "platform", Host: "test1.bdk.oncarlos.com", Msg: "instance woke"}, }, } var buf bytes.Buffer if err := templates.ExecuteTemplate(&buf, "app_logs", p); err != nil { t.Fatalf("render app_logs: %v", err) } html := buf.String() if !strings.Contains(html, `class="term"`) { t.Fatalf("Logs tab has no terminal pane (.term) in:\n%s", html) } if strings.Contains(html, `