nodesRunningMe looked for printed lines in what is a JSON list, so every register tool's "also on" hint and every nodes: "all" named no machine.
156 lines
7.4 KiB
Go
156 lines
7.4 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// askingAController answers the controller's verbs as the seat does: `tools` in-process, the rest as the
|
|
// command's output and its JSON.
|
|
func askingAController(t *testing.T) Ask {
|
|
t.Helper()
|
|
wrap := func(output string, answer any) json.RawMessage {
|
|
raw, _ := json.Marshal(map[string]any{"ok": true, "output": output, "answer": answer})
|
|
return raw
|
|
}
|
|
return func(address string, args any) (json.RawMessage, error) {
|
|
switch address {
|
|
case "seat:mesh-controller.tools":
|
|
return json.RawMessage(`{"seats":[
|
|
{"seat":"mesh-controller","scope":"mesh","tools":[{"name":"node","description":"What one machine reported it can do, what it is assigned, and why.","replaces":["hostnamectl","uptime"]},{"name":"nodes","description":"Every machine."}]},
|
|
{"seat":"node-service-manager","scope":"node","tools":[
|
|
{"name":"status","description":"One unit as the service manager sees it now: its states, whether it starts at boot.","replaces":["systemctl status","systemctl is-active"]},
|
|
{"name":"journal","description":"The last lines of one unit's journal.","replaces":["journalctl"]}]}]}`), nil
|
|
case "seat:mesh-controller.modules":
|
|
return wrap("[...]", []any{map[string]any{"module": "docker", "on": []string{"anchor"},
|
|
"replaces": map[string][]string{"docker_logs": {"docker logs"}}}, map[string]any{"module": "zsh", "on": []string{"anchor"}}}), nil
|
|
case "seat:mesh-controller.nodes":
|
|
return wrap("", []any{map[string]any{"name": "anchor"}, map[string]any{"name": "laptop"}}), nil
|
|
case "seat:mesh-controller.node":
|
|
node, _ := args.(map[string]any)["node"].(string)
|
|
if node == "anchor" {
|
|
return wrap("anchor\n mode converged\n public domain Anchor.Example\n\n what it runs\n", nil), nil
|
|
}
|
|
return wrap("laptop\n mode converged\n", nil), nil
|
|
}
|
|
return nil, errors.New("no such verb")
|
|
}
|
|
}
|
|
|
|
func resolving(name string) []string {
|
|
return map[string][]string{"anchor": {"10.10.0.1"}, "anchor.internal": {"10.10.0.1"}, "anchor.example": {"192.0.2.10"},
|
|
"laptop": {"127.0.0.1", "10.10.0.4"}}[name]
|
|
}
|
|
|
|
// What the controller says of its tools and machines is read whole: the seats' verbs, the modules' own tools,
|
|
// every machine with its domains and addresses — a loopback address never one of them.
|
|
func TestTheMeshsToolsAreAskedOfTheController(t *testing.T) {
|
|
m, err := AskMeshTools(askingAController(t), resolving)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var addresses []string
|
|
for _, r := range m.Replacements {
|
|
addresses = append(addresses, r.Address)
|
|
}
|
|
if got := strings.Join(addresses, ","); got != "mesh-controller.node,node-service-manager.status,node-service-manager.journal,docker.docker_logs" {
|
|
t.Errorf("replacements: %s", got)
|
|
}
|
|
if len(m.Machines) != 2 || strings.Join(m.Machines[0].Domains, ",") != "anchor.internal,anchor.example" ||
|
|
strings.Join(m.Machines[0].Addresses, ",") != "10.10.0.1,192.0.2.10" || strings.Join(m.Machines[1].Addresses, ",") != "10.10.0.4" {
|
|
t.Errorf("machines: %+v", m.Machines)
|
|
}
|
|
if m.Replacements[3].Does != "logs" || !m.Replacements[2].NodeScoped || m.Replacements[0].NodeScoped {
|
|
t.Errorf("the rows: %+v", m.Replacements)
|
|
}
|
|
}
|
|
|
|
// **The table is generated, not written** (novox/hq ADR 0245): what the records say a verb replaces is a row,
|
|
// the machines' seats first, and nothing is rendered where the records say nothing.
|
|
func TestTheInsteadOfTableIsGeneratedFromTheRecords(t *testing.T) {
|
|
m, _ := AskMeshTools(askingAController(t), resolving)
|
|
table := InsteadOf(&m)
|
|
for _, want := range []string{
|
|
"| `<node>/node-service-manager.` | `status` — `systemctl status`, `systemctl is-active` · `journal` — `journalctl` |",
|
|
"| `<node>/docker.` | `docker_logs` — `docker logs` |",
|
|
"| `mesh-controller.` | `node` — `hostnamectl`, `uptime` |",
|
|
"Never `ssh` to a mesh machine",
|
|
} {
|
|
if !strings.Contains(table, want) {
|
|
t.Errorf("the table lacks %q:\n%s", want, table)
|
|
}
|
|
}
|
|
if strings.Index(table, "node-service-manager.") > strings.Index(table, "docker.") ||
|
|
strings.Index(table, "docker.") > strings.Index(table, "`mesh-controller.`") {
|
|
t.Errorf("not the machines' seats, then modules, then the mesh's:\n%s", table)
|
|
}
|
|
// What the guard here named most comes first: its row, and in its row its verb.
|
|
m.Refused = map[string]int{"node-service-manager.journal": 3, "mesh-controller.node": 1}
|
|
ranked := InsteadOf(&m)
|
|
if !strings.Contains(ranked, "| `<node>/node-service-manager.` | `journal` — `journalctl` · `status`") ||
|
|
strings.Index(ranked, "`mesh-controller.`") > strings.Index(ranked, "`<node>/docker.`") {
|
|
t.Errorf("not ranked by the guard's record:\n%s", ranked)
|
|
}
|
|
m.Refused = nil
|
|
if InsteadOf(nil) != "" || InsteadOf(&MeshTools{}) != "" {
|
|
t.Error("a table from nothing")
|
|
}
|
|
// Into the instructions after the mesh's own text, before what was registered.
|
|
out := RenderWithMesh(Facts{Node: "w", Console: "x"}, Settings{}, nil, "/h", nil,
|
|
Config{Mesh: []Item{{Kind: KindInstructions, Name: "conventions", Scope: ScopeMesh, Files: map[string]string{"conventions.md": "Be brief."}}}}, &m)
|
|
md := out["CLAUDE.md"]
|
|
if i, j, k := strings.Index(md, "## Conventions"), strings.Index(md, "## Instead of a shell command"), strings.Index(md, "### conventions"); !(i < j && j < k && i >= 0) {
|
|
t.Errorf("the table is not between the mesh's text and the registered sections:\n%s", md)
|
|
}
|
|
if os.Getenv("SHOW_TABLE") != "" {
|
|
t.Log(table)
|
|
}
|
|
// A long list is cut, and says how the rest is found.
|
|
var many MeshTools
|
|
for i := 0; i < InsteadOfRows+3; i++ {
|
|
many.Replacements = append(many.Replacements, Replacement{Address: "s" + string(rune('a'+i)) + ".v", Seat: true, Replaces: []string{"c"}})
|
|
}
|
|
if t2 := InsteadOf(&many); strings.Count(t2, "| `s") != InsteadOfRows || !strings.Contains(t2, "3 more seats and modules") {
|
|
t.Errorf("not cut to %d rows:\n%s", InsteadOfRows, t2)
|
|
}
|
|
}
|
|
|
|
// Asked again with the same answer, nothing is written and nothing renders.
|
|
func TestTheMeshsToolsAreKeptOnlyWhenTheyChange(t *testing.T) {
|
|
p := Paths{State: t.TempDir()}
|
|
m, _ := AskMeshTools(askingAController(t), resolving)
|
|
if !KeepMeshTools(p, m) {
|
|
t.Fatal("the first answer was not kept")
|
|
}
|
|
if KeepMeshTools(p, m) {
|
|
t.Fatal("the same answer was kept again")
|
|
}
|
|
m.Machines = m.Machines[:1]
|
|
if !KeepMeshTools(p, m) || len(ReadMeshTools(p).Machines) != 1 {
|
|
t.Fatal("a changed answer was not kept")
|
|
}
|
|
d := GuardDataOf(Paths{State: p.State, Node: "laptop"}, ReadMeshTools(p))
|
|
if d.Node != "laptop" || len(d.Replacements) != 4 || !strings.HasSuffix(d.Log, "guard.log") {
|
|
t.Errorf("the guard's data: %+v", d)
|
|
}
|
|
}
|
|
|
|
// Where claude-code runs is read from the controller's `modules` answer as it comes — JSON, from `module list
|
|
// --json` — and from the printed list too.
|
|
func TestTheMachinesRunningTheModuleAreReadFromTheControllersAnswer(t *testing.T) {
|
|
asJSON := json.RawMessage(`{"ok":true,"output":"[...]","answer":[{"module":"zsh","on":["a"]},{"module":"claude-code","on":["a","b"]}]}`)
|
|
if got := strings.Join(NodesRunning(asJSON, "claude-code"), ","); got != "a,b" {
|
|
t.Errorf("from JSON: %q", got)
|
|
}
|
|
printed := json.RawMessage(`{"ok":true,"output":"zsh 1 built x on a\nclaude-code 1 built y on a, b\n"}`)
|
|
if got := strings.Join(NodesRunning(printed, "claude-code"), ","); got != "a,b" {
|
|
t.Errorf("from the printed list: %q", got)
|
|
}
|
|
if got := NodesRunning(json.RawMessage(`{"ok":true,"answer":[{"module":"zsh","on":["a"]}]}`), "claude-code"); got != nil {
|
|
t.Errorf("a module that runs nowhere: %v", got)
|
|
}
|
|
}
|