package main // i3's shape (novox/hq ADR 0208): it holds node-display-session and requires the X display on its own // machine; it contributes the session's exec to the last xinitrc slot and the desktop's identity to // the environment; it owns the main configuration, which ends by including the drop-in directory, and // the login manager's session entry, which runs the session's start. import ( "encoding/json" "os" "os/exec" "path/filepath" "sort" "strings" "testing" ) type manifest struct { Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []struct { Name string `json:"name"` Scope string `json:"scope"` Serves []string `json:"serves"` } `json:"claims"` Seats any `json:"seats"` Tools []string Environment struct { Variables map[string]string `json:"variables"` } `json:"environment"` Shell []struct { For, Slot, Code string } `json:"shell"` Resources []map[string]any `json:"resources"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } 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 resource(t *testing.T, m manifest, id string) map[string]any { for _, r := range m.Resources { if r["id"] == id { return r } } t.Fatalf("no resource %s", id) return nil } func TestItHoldsTheSessionSeatOnAMachineWithAnXDisplay(t *testing.T) { m := readManifest(t) if m.Seats != nil || len(m.Claims) != 1 || m.Claims[0].Name != "node-display-session" || strings.Join(m.Claims[0].Serves, ",") != "reload,workspaces,windows" { t.Fatalf("%+v", m.Claims) } if strings.Join(m.Requires, ",") != "x11-display" { t.Fatalf("requires %v", m.Requires) } served := map[string]bool{} for _, tool := range tools(&i3{}) { served[tool.Name] = true } for _, v := range m.Claims[0].Serves { if !served["node-display-session."+v] { t.Errorf("the seat's %s is not served", v) } } if len(served) != len(m.Tools)+3 { t.Fatalf("served %d, listed %d", len(served), len(m.Tools)) } for _, n := range m.Tools { if !served[n] || !strings.HasPrefix(n, "i3_") { t.Errorf("%s", n) } } } func TestItContributesTheSessionsExecLastAndTheDesktopsIdentity(t *testing.T) { m := readManifest(t) if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "last" { t.Fatalf("%+v", m.Shell) } var code []string for _, l := range strings.Split(strings.TrimSpace(m.Shell[0].Code), "\n") { if !strings.HasPrefix(l, "#") { code = append(code, l) } } if len(code) != 1 || !strings.HasPrefix(code[0], "exec i3") { t.Fatalf("one line, the exec: %q", code) } if v := m.Environment.Variables; len(v) != 2 || v["XDG_CURRENT_DESKTOP"] != "i3" || v["XDG_SESSION_DESKTOP"] != "i3" { t.Fatalf("%v", v) } } func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testing.T) { m := readManifest(t) r := resource(t, m, "config") raw, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) if r["content"] != string(raw) || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil { t.Fatal("the manifest carries config/config whole, as an owned file") } c := string(raw) lines := strings.Split(strings.TrimSpace(c), "\n") if lines[len(lines)-1] != "include ~/.config/i3/config.d/*.conf" { t.Fatal("the drop-ins are read last, with every variable in scope") } var lines2 []string for _, l := range lines { if !strings.HasPrefix(strings.TrimSpace(l), "#") { lines2 = append(lines2, l) } } code := strings.Join(lines2, "\n") for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm", "exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/", // carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring "rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring", // the peripherals' tray: polychromatic's contribution "polychromatic"} { if strings.Contains(code, gone) { t.Errorf("the configuration still holds %q", gone) } } if !strings.Contains(c, "\nfont pango:JetBrainsMono Nerd Font 11\n") || !strings.Contains(c, "exec --no-startup-id dex --autostart --environment i3") { t.Fatal("the chosen face for titles; XDG autostart through dex") } if !strings.Contains(c, "bindsym $mod+Delete exec --no-startup-id loginctl lock-session") { t.Fatal("the lock key goes through logind, which the lock screen's module relies on") } if i3, err := exec.LookPath("i3"); err == nil { // As a node with no contribution and no file of the operator's would hold it. alone := strings.Replace(strings.Replace(c, "${contribution:node-display-session:config}", "", 1), "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1) path := filepath.Join(t.TempDir(), "config") os.WriteFile(path, []byte(alone), 0o644) out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput() if err != nil || len(ParseCheck(string(out))) > 0 { t.Fatalf("i3 -C: %v %s", err, out) } } } func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) { m := readManifest(t) r := resource(t, m, "session") if r["path"] != "/etc/lemurs/wms/i3" || r["mode"] != "0755" { t.Fatalf("%v", r) } if !strings.Contains(r["content"].(string), `exec systemd-cat -t x-session /bin/sh "$HOME/.xinitrc"`) { t.Fatal("the entry runs the session's start, never i3 bare") } pkgs := map[string]bool{} for _, x := range m.Resources { if x["type"] == "package" { pkgs[x["package"].(string)] = true } } if !pkgs["i3-wm"] || !pkgs["dex"] || len(pkgs) != 2 { t.Fatalf("%v", pkgs) } a := m.Build.Artifacts[0] if a["from"] != "cmd/i3-tools" || a["binary"] != "i3-tools" || a["language"] != "go" { t.Fatalf("a binary not named i3, so nothing that looks for i3 by name finds the tools: %v", a) } } // Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in // the main file as the controller places them — in module order, each module's under a line naming it // — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and // every line parses. func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) { i3, err := exec.LookPath("i3") if err != nil { t.Skip("no i3 here to check with") } dir := t.TempDir() manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json")) sort.Strings(manifests) var placed strings.Builder var found []string for _, p := range manifests { raw, _ := os.ReadFile(p) var m struct { Module string `json:"module"` Contributions []struct { Seat, Kind, Content string } `json:"contributions"` } json.Unmarshal(raw, &m) named := false for _, c := range m.Contributions { if c.Seat != "node-display-session" || c.Kind != "config" { continue } if !named { placed.WriteString("# " + m.Module + "\n") named = true found = append(found, m.Module) } placed.WriteString(c.Content) if !strings.HasSuffix(c.Content, "\n") { placed.WriteString("\n") } } } if len(found) < 5 { t.Fatalf("expected the launcher, clipboard, wallpaper, bars and the laptop to contribute; found %v", found) } main, _ := os.ReadFile(filepath.Join("..", "..", "config", "config")) text := strings.Replace(string(main), "${contribution:node-display-session:config}", placed.String(), 1) // The operator's own files are not the catalogue's: the include line itself goes (a contribution may // name it in a comment, so only the line that is exactly it). text = strings.Replace(text, "\ninclude ~/.config/i3/config.d/*.conf", "\n", 1) os.WriteFile(filepath.Join(dir, "config"), []byte(text), 0o644) out, err := exec.Command(i3, "-C", "-c", filepath.Join(dir, "config")).CombinedOutput() if err != nil || len(ParseCheck(string(out))) > 0 { t.Fatalf("with the contributions of %v: %v\n%s", found, err, out) } }