The Go runtime answers $SRV.PING, $SRV.INFO and $SRV.STATS (and per name and id) in the io.nats.micro.v1 format with what it serves at the moment it is asked: one service per runtime process, since the bus admits one reply per request from each responder, and one endpoint per tool per subject, its metadata saying module, seat, scope, machine, description, schema and whether the module is interchangeable. Serving is unchanged. The console gathers one $SRV.INFO request's answers instead of asking the catalogue's roster and each module's tools, and reads the controller's records as JSON for what should have answered: an assignment with tools that did not announce is named, a module without tools never is. The text parsers of node list and module list are gone. Packages share the test bus: go test -p 1.
229 lines
9.4 KiB
Go
229 lines
9.4 KiB
Go
package console
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/announce"
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
|
)
|
|
|
|
// text is a tool result's first text, and whether it was an error.
|
|
func text(t *testing.T, reply map[string]any) (string, bool) {
|
|
t.Helper()
|
|
result, ok := reply["result"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("no result: %v", reply)
|
|
}
|
|
content := result["content"].([]any)
|
|
isErr, _ := result["isError"].(bool)
|
|
var parts []string
|
|
for _, c := range content {
|
|
parts = append(parts, c.(map[string]any)["text"].(string))
|
|
}
|
|
return strings.Join(parts, "\n"), isErr
|
|
}
|
|
|
|
func call(t *testing.T, endpoint, tool string, args map[string]any) (string, bool) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "tools/call",
|
|
"params": map[string]any{"name": tool, "arguments": args}})
|
|
return text(t, post(t, endpoint, string(body)))
|
|
}
|
|
|
|
// novox/hq ADR 0195: the console announces five tools, and everything the mesh answers is reached
|
|
// through them by one address per layer.
|
|
func TestTheMeshsToolsAreFoundByAddress(t *testing.T) {
|
|
was := IndexKept
|
|
IndexKept = 0 // every discovery asks the mesh, so a module arriving mid-test is found
|
|
t.Cleanup(func() { IndexKept = was })
|
|
|
|
mesh := mt.New(t)
|
|
// alpha: interchangeable (the mesh issued it a plain subject); beta: state on its machine, holds
|
|
// the node-shelf seat there.
|
|
mesh.Issue(t, mt.MembershipOf("alpha", "desk", true, nil))
|
|
mesh.Issue(t, mt.MembershipOf("beta", "desk", false, map[string][]string{"node-shelf": {"list", "clear"}}))
|
|
nodeTools := connect(t, "node-tools", "desk")
|
|
stop, err := runtime.Run(nodeTools, []runtime.Served{
|
|
{Module: "alpha", Entrypoints: []string{mt.Fixture("many-alpha.serve.mjs")}},
|
|
{Module: "beta", Entrypoints: []string{mt.Fixture("many-beta.serve.mjs")}},
|
|
}, nil, (&mt.Logs{}).Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
|
|
// Nothing may be asked for a roster or a module's `tools` any more (ADR 0197): counted.
|
|
var asked atomic.Int32
|
|
watcher := connect(t, "watcher", "")
|
|
for _, subject := range []string{"mesh.mod.*.tool.tools", "mesh.mod.*.tool.tools.*", "mesh.mod.mesh-catalog.>"} {
|
|
stop, err := watcher.Raw(subject, func(string, []byte) []byte { asked.Add(1); return nil })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
}
|
|
|
|
// The controller: its records as JSON, as its seat verbs answer them, and its own seat's verbs
|
|
// announced on the bus like every runtime's.
|
|
controller := connect(t, "mesh-controller", "bench")
|
|
out := func(s string) map[string]any { return map[string]any{"output": s, "ok": true} }
|
|
serve := func(verb string, answer func() any) {
|
|
stop, err := controller.HandleSubject("mesh.seat.mesh-controller.tool."+verb, func(json.RawMessage) (any, error) {
|
|
return answer(), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stop)
|
|
}
|
|
serve("nodes", func() any {
|
|
return out("the bus's user list leaves out 2 user(s)\n" +
|
|
`[{"name":"bench","heard":"3m ago","mode":"converged","id":"1f2e"},{"name":"desk","heard":"here","mode":"converged","id":"9a8b"}]`)
|
|
})
|
|
var gammaOn atomic.Value
|
|
gammaOn.Store("[]")
|
|
serve("modules", func() any {
|
|
return out(fmt.Sprintf(`[{"module":"alpha","on":["desk"],"tools":true},{"module":"beta","on":["desk"],"tools":true},`+
|
|
`{"module":"gamma","on":%s,"tools":true},{"module":"delta","on":["desk"],"tools":false},`+
|
|
`{"module":"epsilon","on":["bench"],"tools":true}]`, gammaOn.Load().(string)))
|
|
})
|
|
stopAnn, err := announce.Serve(controller, announce.Service{Name: "mesh-controller", ID: "bench"}, func() []announce.Endpoint {
|
|
return []announce.Endpoint{{Kind: announce.KindSeat, Module: "mesh-controller", Seat: "mesh-controller", Scope: "mesh",
|
|
Tool: "nodes", Node: "bench", Description: "Every machine the mesh knows.", Schema: json.RawMessage(`{}`),
|
|
Subject: "mesh.seat.mesh-controller.tool.nodes"}}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(stopAnn)
|
|
controller.Flush()
|
|
watcher.Flush()
|
|
|
|
up, err := Serve(NewSurface(nodeTools, "desk.node-tools"), "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer up.Close()
|
|
endpoint := "http://" + up.Address + "/mcp"
|
|
|
|
// Five tools, nothing else.
|
|
listed := post(t, endpoint, `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`)["result"].(map[string]any)
|
|
var names []string
|
|
for _, x := range listed["tools"].([]any) {
|
|
name := x.(map[string]any)["name"].(string)
|
|
names = append(names, name)
|
|
for _, r := range name {
|
|
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') || len(name) > 64 {
|
|
t.Errorf("%q is not a name the API takes", name)
|
|
}
|
|
}
|
|
}
|
|
if got := strings.Join(names, ","); got != "mesh_overview,mesh_machine,mesh_search,mesh_describe,mesh_call" {
|
|
t.Errorf("announced %s", got)
|
|
}
|
|
|
|
// The overview names the mesh's seats, the machines' seats and the machines.
|
|
overview, isErr := call(t, endpoint, "mesh_overview", nil)
|
|
if isErr || !strings.Contains(overview, "mesh-controller.nodes") || !strings.Contains(overview, "<node>/node-shelf.list") ||
|
|
!strings.Contains(overview, `"bench"`) || !strings.Contains(overview, `"desk"`) {
|
|
t.Errorf("overview: %s", overview)
|
|
}
|
|
// Silence is named only where tools should have answered: epsilon declares tools on bench and
|
|
// nothing there announced it; delta declares none and is never a name.
|
|
if !strings.Contains(overview, "epsilon on bench") || strings.Contains(overview, "delta") {
|
|
t.Errorf("not answering: %s", overview)
|
|
}
|
|
machine, isErr := call(t, endpoint, "mesh_machine", map[string]any{"node": "desk"})
|
|
if isErr || !strings.Contains(machine, "desk/node-shelf.list") || !strings.Contains(machine, "desk/beta.three") ||
|
|
!strings.Contains(machine, "desk/alpha.one") {
|
|
t.Errorf("machine: %s", machine)
|
|
}
|
|
|
|
// A mesh seat, a node seat, an assignment and an interchangeable module, each by address.
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "mesh-controller.nodes"}); isErr || !strings.Contains(got, "converged") {
|
|
t.Errorf("mesh seat: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/node-shelf.list"}); isErr || !strings.Contains(got, `"a"`) {
|
|
t.Errorf("node seat: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "desk/beta.three"}); isErr ||
|
|
!strings.Contains(got, `"beta": 3`) || !strings.Contains(got, "answered by desk") {
|
|
t.Errorf("assignment: %s", got)
|
|
}
|
|
if got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": "alpha.one"}); isErr || !strings.Contains(got, `"alpha": 1`) {
|
|
t.Errorf("interchangeable module: %s", got)
|
|
}
|
|
|
|
// Refused, by name, where the address does not say enough or says the wrong thing.
|
|
for address, want := range map[string]string{
|
|
"beta.three": "keeps state on each machine it runs on, so a call names the machine: write <node>/beta.three — it runs on desk",
|
|
"node-shelf.list": "held once per machine: write <node>/node-shelf.list — it is held on desk",
|
|
"desk/mesh-controller.nodes": "held once for the whole mesh",
|
|
"bench/beta.three": "beta does not run on bench; it runs on desk",
|
|
"nonsense": "is not an address",
|
|
} {
|
|
got, isErr := call(t, endpoint, "mesh_call", map[string]any{"address": address})
|
|
if !isErr || !strings.Contains(got, want) {
|
|
t.Errorf("%s: %s (want %q)", address, got, want)
|
|
}
|
|
}
|
|
|
|
// Described without `node`: the address carries the machine.
|
|
described, isErr := call(t, endpoint, "mesh_describe", map[string]any{"address": "desk/beta.three"})
|
|
if isErr || strings.Contains(described, `"node"`) || !strings.Contains(described, `"address": "desk/beta.three"`) {
|
|
t.Errorf("describe: %s", described)
|
|
}
|
|
|
|
// Search finds across the layers; a module that starts serving after the first answer is found.
|
|
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "shelf"}); !strings.Contains(got, "desk/node-shelf.list") {
|
|
t.Errorf("search a node seat: %s", got)
|
|
}
|
|
if got, _ := call(t, endpoint, "mesh_search", map[string]any{"query": "gamma"}); strings.Contains(got, "gamma.given") {
|
|
t.Fatalf("gamma was found before it served: %s", got)
|
|
}
|
|
mesh.Issue(t, mt.MembershipOf("gamma", "desk", false, nil))
|
|
gammaOn.Store(`["desk"]`)
|
|
late := connect(t, "node-tools", "desk")
|
|
stopLate, err := runtime.Run(late, []runtime.Served{{Module: "gamma", Entrypoints: []string{mt.Fixture("env-gamma.serve.mjs")}}},
|
|
nil, (&mt.Logs{}).Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stopLate()
|
|
var found string
|
|
for i := 0; i < 30; i++ {
|
|
found, _ = call(t, endpoint, "mesh_search", map[string]any{"query": "gamma given"})
|
|
if strings.Contains(found, "desk/gamma.given") {
|
|
break
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
if !strings.Contains(found, "desk/gamma.given") {
|
|
t.Errorf("a module that arrived later was not found: %s", found)
|
|
}
|
|
|
|
if n := asked.Load(); n != 0 {
|
|
t.Errorf("discovery asked a roster or a module's tools %d time(s); it asks the bus", n)
|
|
}
|
|
|
|
// The old names still answer, unannounced.
|
|
if got, isErr := call(t, endpoint, "alpha.one", nil); isErr || !strings.Contains(got, `"alpha": 1`) {
|
|
t.Errorf("an old name: %s", got)
|
|
}
|
|
}
|
|
|
|
func TestTheControllersRecordsAreReadAsJSON(t *testing.T) {
|
|
if got := jsonIn("a warning printed first\n[{\"name\":\"ace\"}]"); got != `[{"name":"ace"}]` {
|
|
t.Errorf("an array after a warning: %q", got)
|
|
}
|
|
if got := jsonIn(`{"seats":[]}`); got != `{"seats":[]}` {
|
|
t.Errorf("an object: %q", got)
|
|
}
|
|
}
|