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.
132 lines
5.4 KiB
Go
132 lines
5.4 KiB
Go
package console
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
"github.com/novox/mesh-tools/node-tools/internal/runtime"
|
|
)
|
|
|
|
func connect(t *testing.T, module, node string) *bus.Conn {
|
|
t.Helper()
|
|
c, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: module, Node: node})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.Logf = func(string, ...any) {}
|
|
t.Cleanup(c.Close)
|
|
return c
|
|
}
|
|
|
|
func post(t *testing.T, endpoint string, body string) map[string]any {
|
|
t.Helper()
|
|
res, err := http.Post(endpoint, "application/json", bytes.NewBufferString(body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer res.Body.Close()
|
|
raw, _ := io.ReadAll(res.Body)
|
|
var out map[string]any
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatalf("%d %s", res.StatusCode, raw)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// As node-tools, the runtime serves the bundles and is the console on loopback: the listing is what
|
|
// the modules and the mesh's records answered, and a call reaches the module on the machine named.
|
|
func TestTheConsoleListsAndCallsOverHTTP(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
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()
|
|
|
|
// The controller's records (ADR 0197): ghost declares tools on desk and nothing announces it.
|
|
controller := connect(t, "mesh-controller", "")
|
|
stopSeat, _ := controller.HandleSubject("mesh.seat.mesh-controller.tool.modules", func(json.RawMessage) (any, error) {
|
|
return map[string]any{"ok": true, "output": `[{"module":"alpha","on":["desk"],"tools":true},` +
|
|
`{"module":"beta","on":["desk"],"tools":true},{"module":"ghost","on":["desk"],"tools":true}]`}, nil
|
|
})
|
|
defer stopSeat()
|
|
controller.Flush()
|
|
|
|
flat := NewSurface(nodeTools, "desk.node-tools")
|
|
flat.Flat = true // the whole catalogue, as before ADR 0195: still reachable, no longer announced
|
|
up, err := Serve(flat, "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer up.Close()
|
|
endpoint := "http://" + up.Address + "/mcp"
|
|
|
|
init := post(t, endpoint, `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{}}}`)
|
|
if !strings.Contains(init["result"].(map[string]any)["instructions"].(string), "reached as desk.node-tools") {
|
|
t.Errorf("initialize: %v", init)
|
|
}
|
|
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) {
|
|
names = append(names, x.(map[string]any)["name"].(string))
|
|
}
|
|
if got := strings.Join(names, ","); got != "alpha.one,alpha.two,beta.five,beta.four,beta.three,node-shelf.clear,node-shelf.list" {
|
|
t.Errorf("listed %s", got)
|
|
}
|
|
if got := listed["_meta"].(map[string]any)["notAnswering"]; len(got.([]any)) != 1 || got.([]any)[0] != "ghost on desk" {
|
|
t.Errorf("not answering: %v", got)
|
|
}
|
|
for _, x := range listed["tools"].([]any) {
|
|
tool := x.(map[string]any)
|
|
if tool["name"] == "node-shelf.list" {
|
|
schema := tool["inputSchema"].(map[string]any)
|
|
if req, _ := schema["required"].([]any); len(req) != 1 || req[0] != "node" {
|
|
t.Errorf("a node seat's verb does not require node: %v", schema)
|
|
}
|
|
}
|
|
}
|
|
|
|
called := post(t, endpoint, `{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"alpha.one","arguments":{"node":"desk"}}}`)
|
|
content := called["result"].(map[string]any)["content"].([]any)
|
|
var got map[string]any
|
|
_ = json.Unmarshal([]byte(content[0].(map[string]any)["text"].(string)), &got)
|
|
if got["alpha"] != float64(1) || content[1].(map[string]any)["text"] != "answered by desk" {
|
|
t.Errorf("called: %v", called)
|
|
}
|
|
seat := post(t, endpoint, `{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{"node":"desk"}}}`)
|
|
if text := seat["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, `"a"`) {
|
|
t.Errorf("seat verb: %v", seat)
|
|
}
|
|
refused := post(t, endpoint, `{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"node-shelf.list","arguments":{}}}`)
|
|
if refused["error"] == nil {
|
|
t.Errorf("a node seat's verb was called without its machine: %v", refused)
|
|
}
|
|
absent := post(t, endpoint, `{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ghost.boo","arguments":{}}}`)
|
|
result := absent["result"].(map[string]any)
|
|
if result["isError"] != true || !strings.Contains(result["content"].([]any)[0].(map[string]any)["text"].(string), "nothing serves ghost.boo") {
|
|
t.Errorf("an absent tool: %v", absent)
|
|
}
|
|
res, err := http.Post(endpoint, "application/json", strings.NewReader(`{"jsonrpc":"2.0","method":"notifications/initialized"}`))
|
|
if err != nil || res.StatusCode != 202 {
|
|
t.Errorf("a notification: %v %v", res, err)
|
|
}
|
|
}
|
|
|
|
func TestTheConsoleListensOnLoopbackAndNowhereElse(t *testing.T) {
|
|
if _, err := Serve(NewSurface(nil, "x"), "0.0.0.0:0"); err == nil || !strings.Contains(err.Error(), "loopback and nowhere else") {
|
|
t.Errorf("a non-loopback console was not refused: %v", err)
|
|
}
|
|
}
|