package main import ( "context" "encoding/json" "os" "path/filepath" "strings" "testing" "adwaita/internal/desktop" ) type manifest struct { Capabilities []string `json:"capabilities"` Claims []any `json:"claims"` Tools []string `json:"tools"` Environment struct { Variables map[string]string `json:"variables"` } `json:"environment"` Shell []struct { For, Slot, Code string } `json:"shell"` Resources []map[string]any `json:"resources"` } func readManifest(t *testing.T) manifest { raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } var m manifest if err := json.Unmarshal(raw, &m); err != nil { t.Fatal(err) } return m } func TestItContributesTheThemesWordsAndClaimsNoSeat(t *testing.T) { m := readManifest(t) if m.Claims != nil { t.Fatal("a theme is not a seat: several coexist") } want := map[string]string{"GTK_THEME": "Adwaita:dark", "GTK2_RC_FILES": "/usr/share/themes/Adwaita-dark/gtk-2.0/gtkrc", "QT_QPA_PLATFORMTHEME": "qt6ct", "QT_STYLE_OVERRIDE": "Fusion", "QT_SELECT": "6", "XCURSOR_THEME": "Adwaita", "XCURSOR_SIZE": "24"} if len(m.Environment.Variables) != len(want) { t.Fatalf("%v", m.Environment.Variables) } for k, v := range want { if m.Environment.Variables[k] != v { t.Errorf("%s=%q", k, m.Environment.Variables[k]) } } served := map[string]bool{} for _, tool := range tools(adwaita{}) { served[tool.Name] = true } if len(served) != len(m.Tools) { t.Fatalf("%v %v", served, m.Tools) } for _, n := range m.Tools { if !served[n] { t.Errorf("%s", n) } } } func TestTheSessionLinesSetGSettingsAndTheResourcesTheCursor(t *testing.T) { m := readManifest(t) if len(m.Shell) != 2 || m.Shell[0].For != "xresources" || m.Shell[1].For != "xinitrc" || m.Shell[0].Slot != "normal" || m.Shell[1].Slot != "normal" { t.Fatalf("%+v", m.Shell) } if m.Shell[0].Code != "! adwaita: the cursor, for X programs that take it from the resources.\nXcursor.theme: Adwaita\nXcursor.size: 24\n" { t.Fatalf("%q", m.Shell[0].Code) } x := m.Shell[1].Code for _, want := range []string{"color-scheme 'prefer-dark'", "gtk-theme 'Adwaita'", "icon-theme 'Adwaita'", "cursor-theme 'Adwaita'", "font-name 'Inter 11'", "monospace-font-name 'JetBrainsMono Nerd Font 11'"} { if !strings.Contains(x, want) { t.Errorf("lacks %s", want) } } for _, l := range strings.Split(strings.TrimSpace(x), "\n") { if !strings.HasPrefix(l, "#") && !strings.HasSuffix(l, "|| true") { t.Errorf("a session line that can stop the session's start: %q", l) } } } func TestItOwnsTheFilesItsSourcesHoldAndNoQt5Duplicate(t *testing.T) { m := readManifest(t) sources := map[string]string{"gtk3": "gtk-settings.ini", "gtk4": "gtk-settings.ini", "qt6ct": "qt6ct.conf", "portals": "portals.conf", "cursor": "cursor-index.theme"} pkgs := []string{} for _, r := range m.Resources { id := r["id"].(string) if r["type"] == "package" { pkgs = append(pkgs, r["package"].(string)) continue } raw, _ := os.ReadFile(filepath.Join("..", "..", "config", sources[id])) if r["content"] != string(raw) || r["into"] != nil || r["owner"] != "${machine:account}" { t.Errorf("%s is not config/%s, whole and the account's", id, sources[id]) } if strings.Contains(r["path"].(string), "qt5ct") { t.Error("qt5ct: both workstations run Qt 6 programs through qt6ct; a second tool is a duplicate") } } if strings.Join(pkgs, ",") != "gnome-themes-extra,adwaita-icon-theme,adwaita-cursors,qt6ct,xdg-desktop-portal-gtk" { t.Fatalf("%v", pkgs) } qt := readINI(filepath.Join("..", "..", "config", "qt6ct.conf")) if qt["Appearance"]["style"] != "Fusion" || qt["Appearance"]["custom_palette"] != "true" || !strings.Contains(qt["Fonts"]["general"], "Inter,11") { t.Fatalf("%v", qt) } if _, err := os.Stat("/usr/share/qt6ct/colors"); err == nil { if _, err := os.Stat(qt["Appearance"]["color_scheme_path"]); err != nil { t.Fatalf("qt6ct ships no %s", qt["Appearance"]["color_scheme_path"]) } } gtk := readINI(filepath.Join("..", "..", "config", "gtk-settings.ini"))["Settings"] if gtk["gtk-theme-name"] != "Adwaita" || gtk["gtk-application-prefer-dark-theme"] != "1" || gtk["gtk-font-name"] != "Inter 11" { t.Fatalf("%v", gtk) } } func TestKeyFilesAreReadWithTheirComments(t *testing.T) { ini := ParseINI("# c\n[A]\nk = v\n; also a comment\n[B]\nx=1=2\n") if ini["A"]["k"] != "v" || ini["B"]["x"] != "1=2" || len(ini) != 2 { t.Fatalf("%v", ini) } } func TestThePortalsAnswerIsResolvedAsXdgDesktopPortalDoes(t *testing.T) { dir := t.TempDir() os.WriteFile(filepath.Join(dir, "gtk.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.gtk\nInterfaces=org.freedesktop.impl.portal.FileChooser;org.freedesktop.impl.portal.Settings;\nUseIn=gnome\n"), 0o644) os.WriteFile(filepath.Join(dir, "gnome-keyring.portal"), []byte("[portal]\nDBusName=org.freedesktop.secrets\nInterfaces=org.freedesktop.impl.portal.Secret;\nUseIn=gnome\n"), 0o644) os.WriteFile(filepath.Join(dir, "kde.portal"), []byte("[portal]\nDBusName=org.freedesktop.impl.portal.desktop.kde\nInterfaces=org.freedesktop.impl.portal.FileChooser;\nUseIn=KDE\n"), 0o644) b := Backends([]string{dir}) if len(b) != 3 || b[0].Name != "gnome-keyring" || len(b[1].Interfaces) != 2 { t.Fatalf("%+v", b) } got := Resolve(b, map[string]string{"default": "gtk", "org.freedesktop.impl.portal.Secret": "gnome-keyring"}, []string{"i3"}) if got["org.freedesktop.impl.portal.FileChooser"] != "gtk" || got["org.freedesktop.impl.portal.Secret"] != "gnome-keyring" || got["org.freedesktop.impl.portal.Settings"] != "gtk" { t.Fatalf("%v", got) } got = Resolve(b, map[string]string{"default": "gtk"}, []string{"i3"}) if got["org.freedesktop.impl.portal.Secret"] != "(none)" { t.Fatalf("gtk does not implement secrets: %v", got) } got = Resolve(b, map[string]string{"default": "none;gtk"}, nil) if got["org.freedesktop.impl.portal.FileChooser"] != "(none)" { t.Fatalf("none stops the list: %v", got) } got = Resolve(b, nil, []string{"KDE"}) if got["org.freedesktop.impl.portal.FileChooser"] != "kde" || got["org.freedesktop.impl.portal.Settings"] != "(none)" { t.Fatalf("with no configuration, UseIn decides: %v", got) } c := PortalConfigs("/h", []string{"i3", "GNOME"}) if c[0] != "/h/.config/xdg-desktop-portal/i3-portals.conf" || c[1] != "/h/.config/xdg-desktop-portal/gnome-portals.conf" || c[2] != "/h/.config/xdg-desktop-portal/portals.conf" { t.Fatalf("%v", c[:3]) } } func TestThemesAreFoundOnceEachWithCursorsAndIcons(t *testing.T) { a, b := t.TempDir(), t.TempDir() os.MkdirAll(filepath.Join(a, "Adwaita", "cursors"), 0o755) os.MkdirAll(filepath.Join(b, "Adwaita"), 0o755) os.WriteFile(filepath.Join(b, "Adwaita", "index.theme"), []byte("[Icon Theme]\nName=Adwaita\nInherits=hicolor\nDirectories=16x16\n"), 0o644) os.MkdirAll(filepath.Join(b, "hicolor"), 0o755) os.WriteFile(filepath.Join(b, "hicolor", "index.theme"), []byte("[Icon Theme]\nName=Hicolor\nDirectories=16x16\n"), 0o644) os.MkdirAll(filepath.Join(b, "empty"), 0o755) got := Themes([]string{a, b}) if len(got) != 2 || got[0].Name != "Adwaita" || !got[0].Cursors || got[0].Icons || got[0].Dir != filepath.Join(a, "Adwaita") || got[1].Name != "hicolor" { t.Fatalf("the first directory's Adwaita hides the second's: %+v", got) } } type fake struct { ran []string get map[string]string } func (f *fake) desk() desktop.Desk { return desktop.Desk{ Find: func() (*desktop.Session, error) { return nil, &desktop.NoSession{Reason: "none"} }, Run: func(_ context.Context, env []string, _ []byte, name string, args ...string) desktop.Result { line := strings.Join(append([]string{name}, args...), " ") f.ran = append(f.ran, line) switch { case name == "gsettings" && args[0] == "get": return desktop.Result{Stdout: "'" + f.get[args[2]] + "'\n"} case name == "gsettings" && args[0] == "set": f.get[args[2]] = args[3] case name == "systemctl": return desktop.Result{Stdout: "GTK_THEME=Adwaita:dark\nNPM_TOKEN=secret\nXDG_CURRENT_DESKTOP=i3\n"} case name == "busctl" && args[1] == "call": return desktop.Result{Stdout: "v u 1\n"} } return desktop.Result{} }, } } func TestAppearanceSwitchesGSettingsAndSaysWhatFollowsAndWhatStays(t *testing.T) { f := &fake{get: map[string]string{"color-scheme": "prefer-dark", "gtk-theme": "Adwaita"}} a := adwaita{d: f.desk(), home: t.TempDir()} got, err := a.appearance(context.Background(), desktop.Args{"mode": "light"}) if err != nil { t.Fatal(err) } j := asJSON(got) if f.get["color-scheme"] != "prefer-light" || !strings.Contains(j, `"switched":"light"`) || !strings.Contains(j, "GTK_THEME=Adwaita:dark") || !strings.Contains(j, `"lasts"`) { t.Fatalf("%s", j) } if strings.Contains(j, "secret") || strings.Contains(j, "NPM_TOKEN") { t.Fatal("only the theme's words are read back from the user manager") } if _, err := a.appearance(context.Background(), desktop.Args{"mode": "blue"}); err == nil { t.Fatal("mode is dark or light") } got, _ = a.appearance(context.Background(), desktop.Args{}) if strings.Contains(asJSON(got), "switched") { t.Fatal("reading changes nothing") } } func TestACursorThemeMustBeInstalledAndWithoutASessionOnlyGSettingsChanges(t *testing.T) { dir := t.TempDir() os.MkdirAll(filepath.Join(dir, "Adwaita", "cursors"), 0o755) f := &fake{get: map[string]string{}} a := adwaita{d: f.desk(), home: t.TempDir(), iconDirs: []string{dir}} if _, err := a.cursor(context.Background(), desktop.Args{"theme": "Bibata"}); err == nil { t.Fatal("a theme that is not installed") } got, err := a.cursor(context.Background(), desktop.Args{"theme": "Adwaita", "size": float64(32)}) if err != nil { t.Fatal(err) } if f.get["cursor-theme"] != "Adwaita" || f.get["cursor-size"] != "32" || !strings.Contains(asJSON(got), "no graphical session") { t.Fatalf("%v %s", f.get, asJSON(got)) } } func asJSON(v any) string { b, _ := json.Marshal(v) return string(b) }