CI / build (push) Successful in 30s
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>
200 lines
7.2 KiB
Go
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&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)
|
|
}
|
|
}
|
|
}
|