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"` Items []struct { Collection string `json:"collection"` Attributes map[string]string `json:"attributes"` Template string `json:"template"` } `json:"items"` 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) } // The module's code and variables are the templates of its items (design 57 L3, issues 795 and 807). for _, it := range m.Items { text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) if err != nil { t.Fatal(err) } parts := strings.Split(it.Collection, ":") switch { case len(parts) == 3 && parts[0] == "shell": m.Shell = append(m.Shell, struct{ For, Slot, Code string }{parts[1], parts[2], string(text)}) case it.Collection == "environment" && it.Attributes["kind"] == "variable": if m.Environment.Variables == nil { m.Environment.Variables = map[string]string{} } m.Environment.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") } } 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("..", "..", "files", "config")) if _, inline := r["content"]; inline || r["template"] != "files/config" || len(raw) == 0 || r["path"] != "${machine:account-home}/.config/i3/config" || r["into"] != nil { t.Fatal("the manifest takes files/config whole as its template, 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 r["template"] != "files/wms/i3" { t.Fatalf("the entry's text is not its template files/wms/i3: %v", r["template"]) } entry, _ := os.ReadFile(filepath.Join("..", "..", "files", "wms", "i3")) if !strings.Contains(string(entry), `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("..", "..", "files", "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) } } // No file resource carries its text inline: each names its template in files/, and the build packs files/ // into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). func TestEveryFileTakesItsTextFromATemplate(t *testing.T) { m := readManifest(t) for _, r := range m.Resources { if _, inline := r["content"]; inline { t.Errorf("resource %v keeps its text inline: keep it in files/ and name it as the template", r["id"]) } if name, ok := r["template"].(string); ok { if _, err := os.Stat(filepath.Join("..", "..", name)); err != nil { t.Errorf("resource %v names %s: %v", r["id"], name, err) } } } packs := 0 for _, a := range m.Build.Artifacts { if a["kind"] == "archive" && a["from"] == "files" { packs++ if a["name"] != "files" { t.Errorf("the templates archive is named %v, not files", a["name"]) } } } if packs != 1 { t.Errorf("the build packs files/ into %d archives, not one", packs) } }