Files
HODA/internal/icons/icons_test.go
T
TMCandClaude Opus 5.5 9d6a921c34
CI / build (push) Successful in 30s
Start HODA: a homelab dashboard built from Docker labels
Apps opt in with sema.dashboard.* labels in their own compose file. The
primary reads its host's containers through a read-only socket proxy,
follows Docker events, and merges in what the agent on the second host
reports. Icons come from the icon label, the dashboard-icons set or the
app's favicon, with a letter as the fallback.

Go with only the standard library, a distroless image, and a Gitea
Actions workflow copied from FiMa's: checks in the Dockerfile's check
stage, a Trivy scan, and a push to git.mariosem.es/msemes/hoda.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:18:20 +01:00

200 lines
7.2 KiB
Go

package icons
import (
"context"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"slices"
"testing"
"time"
"git.mariosem.es/msemes/hoda/internal/apps"
)
var (
svgIcon = []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"><rect width="1" height="1"/></svg>`)
pngIcon = []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89")
quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
)
func serve(t *testing.T, routes map[string][]byte) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, ok := routes[r.URL.Path]
if !ok {
http.NotFound(w, r)
return
}
_, _ = w.Write(body)
}))
t.Cleanup(srv.Close)
return srv
}
func resolveOne(t *testing.T, r *Resolver, app apps.App) Icon {
t.Helper()
r.resolve(context.Background(), app)
ic, _ := r.Lookup(app.ID)
return ic
}
func TestIconSetBeforeFavicon(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon})
site := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
r := New("", cdn.URL, time.Hour, quiet)
app := apps.App{ID: "000000000001", Name: "Git", Image: "gitea/gitea:1.22", URL: site.URL}
ic := resolveOne(t, r, app)
if ic.ContentType != "image/svg+xml" {
t.Errorf("content type = %q, want the SVG from the icon set", ic.ContentType)
}
if r.URL(app.ID) != "/icons/000000000001?v="+ic.Version {
t.Errorf("URL = %q", r.URL(app.ID))
}
}
func TestNameBeforeImage(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/nginx.svg": svgIcon, "/png/nginx.png": pngIcon, "/png/gitea.png": pngIcon, "/svg/immich.svg": svgIcon})
r := New("", cdn.URL, time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/png" {
t.Errorf("Gitea on nginx: content type = %q, want the gitea PNG, not nginx's logo", ic.ContentType)
}
ic = resolveOne(t, r, apps.App{ID: "000000000002", Name: "My Blog", Image: "nginx:alpine", URL: "http://127.0.0.1:1"})
if ic.Version != "" {
t.Error("an app on a generic image got that image's logo")
}
ic = resolveOne(t, r, apps.App{ID: "000000000003", Name: "Photos", Image: "ghcr.io/immich-app/immich-server:release", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/svg+xml" {
t.Errorf("Photos on immich-server: content type = %q, want the immich SVG", ic.ContentType)
}
}
func TestIconLabelWins(t *testing.T) {
cdn := serve(t, map[string][]byte{"/svg/gitea.svg": svgIcon, "/png/forgejo.png": pngIcon})
r := New("", cdn.URL, time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "Gitea", Image: "gitea/gitea", IconHint: "Forgejo.png", URL: "http://127.0.0.1:1"})
if ic.ContentType != "image/png" {
t.Errorf("content type = %q, want the PNG named by the icon label", ic.ContentType)
}
}
func TestFaviconLargestLink(t *testing.T) {
page := []byte(`<html><head>
<link rel="icon" href="/small.png" sizes="16x16">
<link rel="mask-icon" href="/mask.svg">
<link rel=apple-touch-icon href="/touch.png">
<link rel="shortcut icon" href="/big.png" sizes="192x192">
</head></html>`)
site := serve(t, map[string][]byte{"/": page, "/big.png": append(slices.Clone(pngIcon), 'b'), "/small.png": pngIcon})
r := New("", "", time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "No Match", URL: site.URL + "/"})
if string(ic.Data) != string(pngIcon)+"b" {
t.Errorf("got %d bytes, want the 192 px icon", len(ic.Data))
}
}
func TestInternalURLPreferred(t *testing.T) {
internal := serve(t, map[string][]byte{"/favicon.ico": pngIcon})
r := New("", "", time.Hour, quiet)
ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1", InternalURL: internal.URL})
if ic.Version == "" {
t.Error("no icon from internal_url")
}
}
func TestLoginRedirectIsNotAnIcon(t *testing.T) {
login := serve(t, map[string][]byte{"/favicon.ico": pngIcon, "/": []byte(`<link rel="icon" href="/favicon.ico">`)})
app := httptest.NewServer(http.RedirectHandler(login.URL+"/", http.StatusFound))
defer app.Close()
r := New("", "", time.Hour, quiet)
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: app.URL}); ic.Version != "" {
t.Error("took the login page's icon after a redirect to another host")
}
}
func TestHTMLIsNotAnIcon(t *testing.T) {
site := serve(t, map[string][]byte{"/favicon.ico": []byte("<!doctype html><html><body>Sign in</body></html>")})
r := New("", "", time.Hour, quiet)
if ic := resolveOne(t, r, apps.App{ID: "000000000001", Name: "x", URL: site.URL}); ic.Version != "" {
t.Error("took an HTML page as an icon")
}
if r.URL("000000000001") != "" {
t.Error("an app without an icon got an icon URL")
}
}
func TestCacheSurvivesRestart(t *testing.T) {
dir := t.TempDir()
cdn := serve(t, map[string][]byte{"/svg/jellyfin.svg": svgIcon})
app := apps.App{ID: "000000000001", Name: "Jellyfin", URL: "http://127.0.0.1:1"}
first := resolveOne(t, New(dir, cdn.URL, time.Hour, quiet), app)
again := New(dir, cdn.URL, time.Hour, quiet)
ic, ok := again.Lookup(app.ID)
if !ok || ic.Version != first.Version || string(ic.Data) != string(svgIcon) {
t.Fatalf("after restart: ok %v, version %q; want %q", ok, ic.Version, first.Version)
}
again.Ensure([]apps.App{app})
if len(again.queue) != 0 {
t.Error("a fresh cached icon was looked up again")
}
again.Ensure(nil)
if _, ok := again.Lookup(app.ID); ok {
t.Error("the icon of a removed app was kept")
}
}
func TestEnsureRequeuesChangedLabels(t *testing.T) {
r := New("", "", time.Hour, quiet)
app := apps.App{ID: "000000000001", Name: "x", URL: "http://127.0.0.1:1"}
r.entries[app.ID] = &entry{Key: inputKey(app), Checked: time.Now()}
r.Ensure([]apps.App{app})
if len(r.queue) != 0 {
t.Fatal("an unchanged app was queued")
}
app.IconHint = "jellyfin"
r.Ensure([]apps.App{app})
if len(r.queue) != 1 {
t.Error("an app with a new icon label was not queued")
}
}
func TestIconLinks(t *testing.T) {
base, _ := url.Parse("https://app.example/sub/page")
page := []byte(`<LINK REL="icon" HREF="icon.svg"><link rel='icon' href='data:image/png;base64,AA'><link rel="stylesheet" href="x.css"><link rel="icon" href="/a.png?x=1&amp;y=2" sizes="32x32 64x64">`)
got := iconLinks(page, base)
want := []string{"https://app.example/sub/icon.svg", "https://app.example/a.png?x=1&y=2"}
if !slices.Equal(got, want) {
t.Errorf("iconLinks = %q, want %q", got, want)
}
}
func TestImageNames(t *testing.T) {
tests := map[string][]string{
"lscr.io/linuxserver/jellyfin:latest": {"jellyfin"},
"ghcr.io/immich-app/immich-server:release": {"immich-server", "immich"},
"gitea/gitea": {"gitea"},
"localhost:5000/team/vaultwarden@sha256:0": {"vaultwarden"},
"sha256:4f1c2d3e": nil,
"": nil,
}
for image, want := range tests {
if got := imageNames(image); !slices.Equal(got, want) {
t.Errorf("imageNames(%q) = %q, want %q", image, got, want)
}
}
}
func TestSlug(t *testing.T) {
tests := map[string]string{"Uptime Kuma": "uptime-kuma", " Home--Assistant! ": "home-assistant", "ÄBC": "bc", "": ""}
for in, want := range tests {
if got := slug(in); got != want {
t.Errorf("slug(%q) = %q, want %q", in, got, want)
}
}
}