Add optional PNG screenshot mode
PNG screenshots are now gated by WEBTERM_SCREENSHOT_MODE. The dashboard selects SVG by default and switches to PNG when enabled, with ETag caching and eviction for both formats. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -97,6 +97,7 @@ go run ./cmd/webterm -- --compose-manifest ./docker-compose.yaml
|
||||
- `WEBTERM_DOCKER_USERNAME`: user for Docker exec sessions
|
||||
- `WEBTERM_DOCKER_AUTO_COMMAND`: override auto command (`/bin/bash` default)
|
||||
- `WEBTERM_SCREENSHOT_FORCE_REDRAW`: force redraw before screenshots (`true/1/yes/on`)
|
||||
- `WEBTERM_SCREENSHOT_MODE`: screenshot format for dashboard thumbnails (`svg` default, set `png` to enable PNG)
|
||||
- `DOCKER_HOST`: Docker daemon endpoint override
|
||||
|
||||
## Development (Makefile-first)
|
||||
|
||||
@@ -15,6 +15,7 @@ const (
|
||||
DefaultTerminalHeight = 45
|
||||
|
||||
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
|
||||
ScreenshotModeEnv = "WEBTERM_SCREENSHOT_MODE"
|
||||
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
|
||||
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
|
||||
DockerHostEnv = "DOCKER_HOST"
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
package webterm
|
||||
|
||||
import "unicode"
|
||||
|
||||
const (
|
||||
coverageSpace = 0
|
||||
coveragePunctLight = 32
|
||||
coveragePunct = 64
|
||||
coverageLower = 96
|
||||
coverageUpper = 112
|
||||
coverageDigit = 104
|
||||
coverageDefault = 96
|
||||
)
|
||||
|
||||
var firaCodeCoverage = map[rune]uint8{}
|
||||
|
||||
func init() {
|
||||
firaCodeCoverage[' '] = coverageSpace
|
||||
for _, r := range []rune(".,:;'\"") {
|
||||
firaCodeCoverage[r] = coveragePunctLight
|
||||
}
|
||||
for _, r := range []rune("`") {
|
||||
firaCodeCoverage[r] = coveragePunctLight
|
||||
}
|
||||
for _, r := range []rune("+-*/=<>()[]{}") {
|
||||
firaCodeCoverage[r] = coveragePunct
|
||||
}
|
||||
for _, r := range []rune("|!#@$%^&?_") {
|
||||
firaCodeCoverage[r] = coveragePunct
|
||||
}
|
||||
|
||||
// Box drawing block — approximate light line density.
|
||||
for r := rune(0x2500); r <= 0x257F; r++ {
|
||||
firaCodeCoverage[r] = 72
|
||||
}
|
||||
|
||||
// Block elements and shading.
|
||||
firaCodeCoverage['░'] = 64
|
||||
firaCodeCoverage['▒'] = 128
|
||||
firaCodeCoverage['▓'] = 192
|
||||
firaCodeCoverage['█'] = 255
|
||||
firaCodeCoverage['▀'] = 128
|
||||
firaCodeCoverage['▄'] = 128
|
||||
firaCodeCoverage['▌'] = 128
|
||||
firaCodeCoverage['▐'] = 128
|
||||
|
||||
verticalBlocks := []rune{'▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'}
|
||||
for i, r := range verticalBlocks {
|
||||
firaCodeCoverage[r] = uint8((i + 1) * 255 / len(verticalBlocks))
|
||||
}
|
||||
leftBlocks := []rune{'▏', '▎', '▍', '▌', '▋', '▊', '▉', '█'}
|
||||
for i, r := range leftBlocks {
|
||||
firaCodeCoverage[r] = uint8((i + 1) * 255 / len(leftBlocks))
|
||||
}
|
||||
}
|
||||
|
||||
func coverageForRune(r rune) uint8 {
|
||||
if value, ok := firaCodeCoverage[r]; ok {
|
||||
return value
|
||||
}
|
||||
if unicode.IsSpace(r) {
|
||||
return coverageSpace
|
||||
}
|
||||
switch {
|
||||
case r >= '0' && r <= '9':
|
||||
return coverageDigit
|
||||
case r >= 'A' && r <= 'Z':
|
||||
return coverageUpper
|
||||
case r >= 'a' && r <= 'z':
|
||||
return coverageLower
|
||||
}
|
||||
if unicode.IsPunct(r) || unicode.IsSymbol(r) {
|
||||
return coveragePunct
|
||||
}
|
||||
return coverageDefault
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package webterm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/rcarmo/webterm/internal/terminalstate"
|
||||
)
|
||||
|
||||
const (
|
||||
pngCharWidth = 8
|
||||
pngCellHeight = 17
|
||||
pngPadding = 10
|
||||
)
|
||||
|
||||
func RenderTerminalPNG(
|
||||
buffer [][]terminalstate.Cell,
|
||||
width, height int,
|
||||
background, foreground string,
|
||||
palette map[string]string,
|
||||
) ([]byte, error) {
|
||||
if background == "" {
|
||||
background = "#000000"
|
||||
}
|
||||
if foreground == "" {
|
||||
foreground = "#d3d7cf"
|
||||
}
|
||||
if palette == nil {
|
||||
palette = ansiColors
|
||||
}
|
||||
cellHeight := pngCellHeight
|
||||
imgWidth := width*pngCharWidth + pngPadding*2
|
||||
imgHeight := height*cellHeight + pngPadding*2
|
||||
if imgWidth <= 0 || imgHeight <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
bgColor := mustParseHexColor(background)
|
||||
img := image.NewRGBA(image.Rect(0, 0, imgWidth, imgHeight))
|
||||
draw.Draw(img, img.Bounds(), &image.Uniform{bgColor}, image.Point{}, draw.Src)
|
||||
|
||||
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
|
||||
row := buffer[rowIdx]
|
||||
rectY := pngPadding + rowIdx*cellHeight
|
||||
for col := 0; col < len(row); col++ {
|
||||
cell := row[col]
|
||||
if cell.Data == "" && cell.BG == "" {
|
||||
continue
|
||||
}
|
||||
x := pngPadding + col*pngCharWidth
|
||||
fg := colorToHex(cell.FG, true, palette, foreground, background)
|
||||
bg := colorToHex(cell.BG, false, palette, foreground, background)
|
||||
if cell.Reverse {
|
||||
fg, bg = bg, fg
|
||||
}
|
||||
bgColor := mustParseHexColor(bg)
|
||||
coverage := uint8(0)
|
||||
if cell.Data != "" {
|
||||
coverage = coverageForRune(firstRune(cell.Data))
|
||||
}
|
||||
cellColor := blendColors(mustParseHexColor(fg), bgColor, coverage)
|
||||
draw.Draw(
|
||||
img,
|
||||
image.Rect(x, rectY, x+pngCharWidth, rectY+cellHeight),
|
||||
&image.Uniform{cellColor},
|
||||
image.Point{},
|
||||
draw.Src,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func firstRune(value string) rune {
|
||||
for _, r := range value {
|
||||
return r
|
||||
}
|
||||
return ' '
|
||||
}
|
||||
|
||||
func blendColors(fg, bg color.RGBA, coverage uint8) color.RGBA {
|
||||
inv := 255 - uint16(coverage)
|
||||
cov := uint16(coverage)
|
||||
r := (uint16(fg.R)*cov + uint16(bg.R)*inv + 127) / 255
|
||||
g := (uint16(fg.G)*cov + uint16(bg.G)*inv + 127) / 255
|
||||
b := (uint16(fg.B)*cov + uint16(bg.B)*inv + 127) / 255
|
||||
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
||||
}
|
||||
|
||||
func mustParseHexColor(value string) color.RGBA {
|
||||
value = strings.TrimSpace(strings.TrimPrefix(value, "#"))
|
||||
if len(value) != 6 {
|
||||
return color.RGBA{A: 255}
|
||||
}
|
||||
r, err := strconv.ParseUint(value[0:2], 16, 8)
|
||||
if err != nil {
|
||||
return color.RGBA{A: 255}
|
||||
}
|
||||
g, err := strconv.ParseUint(value[2:4], 16, 8)
|
||||
if err != nil {
|
||||
return color.RGBA{A: 255}
|
||||
}
|
||||
b, err := strconv.ParseUint(value[4:6], 16, 8)
|
||||
if err != nil {
|
||||
return color.RGBA{A: 255}
|
||||
}
|
||||
return color.RGBA{R: uint8(r), G: uint8(g), B: uint8(b), A: 255}
|
||||
}
|
||||
+147
-4
@@ -52,6 +52,12 @@ type screenshotCacheEntry struct {
|
||||
etag string
|
||||
}
|
||||
|
||||
type screenshotPNGCacheEntry struct {
|
||||
when time.Time
|
||||
png []byte
|
||||
etag string
|
||||
}
|
||||
|
||||
type wsClient struct {
|
||||
routeKey string
|
||||
conn *websocket.Conn
|
||||
@@ -164,6 +170,7 @@ type LocalServer struct {
|
||||
theme string
|
||||
fontFamily string
|
||||
fontSize int
|
||||
screenshotMode string
|
||||
|
||||
sessionManager *SessionManager
|
||||
landingApps []App
|
||||
@@ -177,6 +184,7 @@ type LocalServer struct {
|
||||
mu sync.RWMutex
|
||||
wsClients map[string]*wsClient
|
||||
screenshotCache map[string]screenshotCacheEntry
|
||||
screenshotPNGCache map[string]screenshotPNGCacheEntry
|
||||
routeLastActivity map[string]time.Time
|
||||
routeLastSSE map[string]time.Time
|
||||
sseSubscribers map[chan string]struct{}
|
||||
@@ -231,6 +239,10 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
||||
if fontSize <= 0 {
|
||||
fontSize = DefaultFontSize
|
||||
}
|
||||
screenshotMode := strings.ToLower(strings.TrimSpace(os.Getenv(ScreenshotModeEnv)))
|
||||
if screenshotMode != "png" {
|
||||
screenshotMode = "svg"
|
||||
}
|
||||
apps := append([]App{}, config.Apps...)
|
||||
for _, app := range options.LandingApps {
|
||||
apps = append(apps, app)
|
||||
@@ -241,6 +253,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
||||
theme: theme,
|
||||
fontFamily: options.FontFamily,
|
||||
fontSize: fontSize,
|
||||
screenshotMode: screenshotMode,
|
||||
|
||||
sessionManager: NewSessionManager(apps),
|
||||
landingApps: append([]App{}, options.LandingApps...),
|
||||
@@ -253,6 +266,7 @@ func NewLocalServer(config Config, options ServerOptions) *LocalServer {
|
||||
},
|
||||
wsClients: map[string]*wsClient{},
|
||||
screenshotCache: map[string]screenshotCacheEntry{},
|
||||
screenshotPNGCache: map[string]screenshotPNGCacheEntry{},
|
||||
routeLastActivity: map[string]time.Time{},
|
||||
routeLastSSE: map[string]time.Time{},
|
||||
sseSubscribers: map[chan string]struct{}{},
|
||||
@@ -812,6 +826,118 @@ func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
||||
writeSVG(responseSVG, responseETag)
|
||||
}
|
||||
|
||||
func (s *LocalServer) handleScreenshotPNG(w http.ResponseWriter, r *http.Request) {
|
||||
if s.screenshotMode != "png" {
|
||||
http.Error(w, "PNG screenshots disabled", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
download := r.URL.Query().Get("download") == "1"
|
||||
routeKey := r.URL.Query().Get("route_key")
|
||||
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
|
||||
if !ok && routeKey != "" {
|
||||
if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
|
||||
_ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
|
||||
deadline := time.Now().Add(500 * time.Millisecond)
|
||||
for {
|
||||
session = s.sessionManager.GetSessionByRouteKey(routeKey)
|
||||
if session != nil {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ok || session == nil {
|
||||
http.Error(w, "Session not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
writeNotModified := func(etag string) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
}
|
||||
writePNG := func(pngBytes []byte, etag string) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("ETag", etag)
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
if download {
|
||||
filename := sanitizeFilenameToken(routeKey) + "-screenshot.png"
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
}
|
||||
_, _ = w.Write(pngBytes)
|
||||
}
|
||||
useConditional := !download
|
||||
|
||||
s.mu.RLock()
|
||||
cached, hasCached := s.screenshotPNGCache[routeKey]
|
||||
lastActivity := s.routeLastActivity[routeKey]
|
||||
s.mu.RUnlock()
|
||||
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) && len(cached.png) > 0 {
|
||||
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||
if !lastActivity.After(cached.when) {
|
||||
writeNotModified(cached.etag)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
writePNG(cached.png, cached.etag)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if s.screenshotForceRedraw {
|
||||
_ = session.ForceRedraw()
|
||||
}
|
||||
|
||||
snapshot := session.GetScreenSnapshot()
|
||||
if hasCached && !snapshot.HasChanges && len(cached.png) > 0 {
|
||||
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
||||
writeNotModified(cached.etag)
|
||||
return
|
||||
}
|
||||
writePNG(cached.png, cached.etag)
|
||||
return
|
||||
}
|
||||
|
||||
app, _ := s.sessionManager.AppBySlug(routeKey)
|
||||
theme := strings.ToLower(strings.TrimSpace(app.Theme))
|
||||
if theme == "" {
|
||||
theme = strings.ToLower(s.theme)
|
||||
}
|
||||
palette := ThemePalettes[theme]
|
||||
if palette == nil {
|
||||
palette = ThemePalettes["xterm"]
|
||||
}
|
||||
background := palette["background"]
|
||||
if background == "" {
|
||||
background = ThemeBackgrounds["xterm"]
|
||||
}
|
||||
foreground := palette["foreground"]
|
||||
if foreground == "" {
|
||||
foreground = "#e5e5e5"
|
||||
}
|
||||
|
||||
pngBytes, err := RenderTerminalPNG(snapshot.Buffer, snapshot.Width, snapshot.Height, background, foreground, palette)
|
||||
if err != nil || len(pngBytes) == 0 {
|
||||
http.Error(w, "Screenshot render failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
hash := sha1.Sum(pngBytes)
|
||||
etag := fmt.Sprintf(`"%x"`, hash[:])
|
||||
s.mu.Lock()
|
||||
s.screenshotPNGCache[routeKey] = screenshotPNGCacheEntry{when: time.Now(), png: pngBytes, etag: etag}
|
||||
s.mu.Unlock()
|
||||
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
||||
writeNotModified(etag)
|
||||
return
|
||||
}
|
||||
writePNG(pngBytes, etag)
|
||||
}
|
||||
|
||||
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
|
||||
container := r.URL.Query().Get("container")
|
||||
if strings.TrimSpace(container) == "" {
|
||||
@@ -960,6 +1086,14 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
if s.dockerWatch {
|
||||
dockerWatchJS = "true"
|
||||
}
|
||||
screenshotEndpoint := "/screenshot.svg"
|
||||
screenshotDownloadQuery := "sanitize_font_urls=1&download=1"
|
||||
screenshotDownloadExt := "svg"
|
||||
if s.screenshotMode == "png" {
|
||||
screenshotEndpoint = "/screenshot.png"
|
||||
screenshotDownloadQuery = "download=1"
|
||||
screenshotDownloadExt = "png"
|
||||
}
|
||||
html := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -1016,6 +1150,9 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
let tiles = %s;
|
||||
const composeMode = %s;
|
||||
const dockerWatchMode = %s;
|
||||
const screenshotEndpoint = %q;
|
||||
const screenshotDownloadQuery = %q;
|
||||
const screenshotDownloadExt = %q;
|
||||
let cardsBySlug = {};
|
||||
|
||||
let searchQuery = '';
|
||||
@@ -1035,8 +1172,8 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
function downloadSanitizedScreenshot(slug) {
|
||||
if (!slug) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = '/screenshot.svg?route_key=' + encodeURIComponent(slug) + '&sanitize_font_urls=1&download=1&_t=' + Date.now();
|
||||
link.download = slug + '-screenshot.svg';
|
||||
link.href = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug) + '&' + screenshotDownloadQuery + '&_t=' + Date.now();
|
||||
link.download = slug + '-screenshot.' + screenshotDownloadExt;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
@@ -1282,7 +1419,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
return;
|
||||
}
|
||||
screenshotRequestInFlight = true;
|
||||
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
|
||||
const url = screenshotEndpoint + '?route_key=' + encodeURIComponent(slug);
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
const headers = {};
|
||||
@@ -1468,7 +1605,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS)
|
||||
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadQuery, screenshotDownloadExt)
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, html)
|
||||
return
|
||||
@@ -1604,6 +1741,7 @@ func (s *LocalServer) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/ws/", s.handleWebSocket)
|
||||
mux.HandleFunc("/screenshot.svg", s.handleScreenshot)
|
||||
mux.HandleFunc("/screenshot.png", s.handleScreenshotPNG)
|
||||
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
|
||||
mux.HandleFunc("/events", s.handleEvents)
|
||||
mux.HandleFunc("/health", s.handleHealth)
|
||||
@@ -1633,6 +1771,11 @@ func (s *LocalServer) evictStaleScreenshots(ctx context.Context) {
|
||||
delete(s.screenshotCache, key)
|
||||
}
|
||||
}
|
||||
for key, entry := range s.screenshotPNGCache {
|
||||
if time.Since(entry.when) > maxScreenshotCacheTTL {
|
||||
delete(s.screenshotPNGCache, key)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -455,6 +455,38 @@ func TestScreenshotAndETag(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshotPNGAndETag(t *testing.T) {
|
||||
t.Setenv(ScreenshotModeEnv, "png")
|
||||
server, httpServer, _ := newServerForTests(t, false)
|
||||
if _, err := server.sessionManager.NewSession("shell", "sid", "shell", 80, 24); err != nil {
|
||||
t.Fatalf("NewSession error = %v", err)
|
||||
}
|
||||
resp, err := http.Get(httpServer.URL + "/screenshot.png?route_key=shell")
|
||||
if err != nil {
|
||||
t.Fatalf("screenshot request error = %v", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
etag := resp.Header.Get("ETag")
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if etag == "" || len(body) < 8 || string(body[:8]) != "\x89PNG\r\n\x1a\n" {
|
||||
t.Fatalf("expected png body and etag")
|
||||
}
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.png?route_key=shell", nil)
|
||||
req.Header.Set("If-None-Match", etag)
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("etag request error = %v", err)
|
||||
}
|
||||
_ = resp2.Body.Close()
|
||||
if resp2.StatusCode != http.StatusNotModified {
|
||||
t.Fatalf("expected 304, got %d", resp2.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
|
||||
_, httpServer, _ := newServerForTests(t, false)
|
||||
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
|
||||
@@ -502,6 +534,24 @@ func TestDashboardIncludesContextMenuSanitizedDownload(t *testing.T) {
|
||||
if !strings.Contains(text, "contextmenu") || !strings.Contains(text, "sanitize_font_urls=1&download=1") {
|
||||
t.Fatalf("expected contextmenu sanitized download wiring in dashboard page")
|
||||
}
|
||||
if !strings.Contains(text, "screenshot.svg") {
|
||||
t.Fatalf("expected dashboard to request svg screenshots by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardUsesPNGWhenEnabled(t *testing.T) {
|
||||
t.Setenv(ScreenshotModeEnv, "png")
|
||||
_, httpServer, _ := newServerForTests(t, true)
|
||||
resp, err := http.Get(httpServer.URL + "/")
|
||||
if err != nil {
|
||||
t.Fatalf("dashboard request error = %v", err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
text := string(body)
|
||||
if !strings.Contains(text, "screenshot.png") {
|
||||
t.Fatalf("expected dashboard to request png screenshots when enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user