Files
mesh-catalog/modules/i3/cmd/i3-tools/manifest_test.go
T

288 lines
10 KiB
Go

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)
}
}