package main import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) // The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in // every desktop module that carries it. type manifest struct { Module string `json:"module"` Version string `json:"version"` Moved bool `json:"moved"` Items []item `json:"items"` Capabilities []string `json:"capabilities"` Requires []string `json:"requires"` Claims []claim `json:"claims"` Seats []any `json:"seats"` Tools []string `json:"tools"` Environment *environment `json:"environment"` Shell []shellCode `json:"shell"` Resources []map[string]any `json:"resources"` // Lines for other modules' seats (novox/hq ADR 0212): the window manager's, here. Contributions []any `json:"contributions"` Build struct { Artifacts []map[string]any `json:"artifacts"` } `json:"build"` } type claim struct { Name string `json:"name"` Scope string `json:"scope"` Serves []string `json:"serves"` // How the holder renders each kind its seat receives as data (novox/hq ADR 0255). Renders map[string]string `json:"renders"` } type environment struct { Variables map[string]string `json:"variables"` Path []map[string]any `json:"path"` } type shellCode struct { For string `json:"for"` Slot string `json:"slot"` Code string `json:"code"` } // An item of a collection (novox/hq ADR 0349 clause 13): its text is the template file beside the manifest. type item struct { Collection string `json:"collection"` Name string `json:"name"` Attributes map[string]string `json:"attributes"` Template string `json:"template"` } // shell is the code the module adds to a shell's or the session's start, read from the templates of its items of // the collections shell::, in the order they are declared. func (m manifest) shell(t *testing.T) []shellCode { t.Helper() var out []shellCode for _, it := range m.Items { parts := strings.Split(it.Collection, ":") if len(parts) != 3 || parts[0] != "shell" { continue } text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) if err != nil { t.Fatal(err) } out = append(out, shellCode{For: parts[1], Slot: parts[2], Code: string(text)}) } return out } // environmentOf is the variables the module sets, read from the templates of its items of the environment. func (m manifest) environmentOf(t *testing.T) *environment { t.Helper() var env *environment for _, it := range m.Items { if it.Collection != "environment" || it.Attributes["kind"] != "variable" { continue } text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) if err != nil { t.Fatal(err) } if env == nil { env = &environment{Variables: map[string]string{}} } env.Variables[it.Attributes["name"]] = strings.TrimSuffix(string(text), "\n") } return env } func readManifest(t *testing.T) manifest { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } dec := json.NewDecoder(strings.NewReader(string(raw))) dec.DisallowUnknownFields() var m manifest if err := dec.Decode(&m); err != nil { t.Fatalf("module.json: %v", err) } m.Environment = m.environmentOf(t) return m } func (m manifest) resource(t *testing.T, id string) map[string]any { t.Helper() for _, r := range m.Resources { if r["id"] == id { return r } } t.Fatalf("no resource %q", id) return nil } func (m manifest) packages() (present, absent []string) { for _, r := range m.Resources { if r["type"] == "package" { if r["absent"] == true { absent = append(absent, r["package"].(string)) } else { present = append(present, r["package"].(string)) } } } return present, absent } // fromTemplate checks that a file resource takes its text from its template file in the module's // repository (novox/hq ADR 0349 §2): it names the template, carries no text of its own beside it, and the // template is there. The readable file in the repository is the one place the machine's text is written. func (m manifest) fromTemplate(t *testing.T, id, template string) { t.Helper() r := m.resource(t, id) if r["type"] != "file" { t.Fatalf("%s is a %v, not a file", id, r["type"]) } if r["template"] != template { t.Fatalf("resource %s does not take its text from %s: template %v", id, template, r["template"]) } if _, inline := r["content"]; inline { t.Fatalf("resource %s keeps its text inline beside its template: a file's text is in one place", id) } if _, err := os.Stat(filepath.Join("..", "..", template)); err != nil { t.Fatalf("resource %s names %s: %v", id, template, err) } if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") { t.Fatalf("%s under the home is the account's", id) } } // everyFileIsATemplate checks that no file resource carries its text inline, and that the build packs // files/ into the one archive the controller reads the templates back from (novox/hq ADR 0349 §2). func (m manifest) everyFileIsATemplate(t *testing.T) { t.Helper() 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"]) } } 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) } } // text is the text a file resource places on the machine: its template file's. func (m manifest) text(t *testing.T, id string) string { t.Helper() name, _ := m.resource(t, id)["template"].(string) if name == "" { t.Fatalf("resource %s names no template", id) } raw, err := os.ReadFile(filepath.Join("..", "..", name)) if err != nil { t.Fatal(err) } return string(raw) } // checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle // serves each seat verb the claims promise as ., and that the Go bundle is declared. func checkTheToolsAgree(t *testing.T, m manifest) { t.Helper() own, seat := map[string]bool{}, map[string]bool{} for _, tool := range tools() { if strings.Contains(tool.Name, ".") { seat[tool.Name] = true } else { own[tool.Name] = true } if strings.TrimSpace(tool.Description) == "" { t.Errorf("%s has no description", tool.Name) } } listed := map[string]bool{} for _, name := range m.Tools { listed[name] = true if !own[name] { t.Errorf("module.json lists %s, which the bundle does not serve", name) } } for name := range own { if !listed[name] { t.Errorf("the bundle serves %s, which module.json does not list", name) } if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") { t.Errorf("%s is not prefixed with the module's name", name) } } promised := map[string]bool{} for _, c := range m.Claims { for _, verb := range c.Serves { promised[c.Name+"."+verb] = true if !seat[c.Name+"."+verb] { t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb) } } } for name := range seat { if !promised[name] { t.Errorf("the bundle serves %s, which no claim promises", name) } } var bundle map[string]any for _, a := range m.Build.Artifacts { if a["kind"] == "bundle" { bundle = a } } if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" || bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" { t.Errorf("the Go tools bundle: %v", bundle) } } // checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry. func checkNoSecretsOrInstallationNames(t *testing.T) { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) if err != nil { t.Fatal(err) } s := strings.ToLower(string(raw)) for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} { if strings.Contains(s, never) { t.Errorf("module.json names %q", never) } } } // displaySession is the collection the module's lines for the window manager are an item of. const displaySession = "contribution:node-display-session:config" // i3Lines is what the module contributes to the window manager: the text of its item of the collection // contribution:node-display-session:config (design 57 L3, issue 848). func (m manifest) i3Lines() string { var out string for _, it := range m.Items { if it.Collection == displaySession { text, err := os.ReadFile(filepath.Join("..", "..", it.Template)) if err != nil { return "" } out += string(text) } } return out } // i3LinesAreSource checks the window-manager lines are one item whose template is the source file, and that no // old-form contribution to node-display-session is left. func (m manifest) i3LinesAreSource(t *testing.T, source string) { t.Helper() if len(m.Contributions) != 0 { t.Fatalf("the module still holds contributions: the lines are an item of %s", displaySession) } var templates []string for _, it := range m.Items { if it.Collection == displaySession { templates = append(templates, it.Template) } } if len(templates) != 1 || templates[0] != source { t.Fatalf("the items of %s are %v, want the one template %s", displaySession, templates, source) } if m.i3Lines() == "" { t.Fatalf("the template %s holds no line", source) } }