The runtime knows no language, so nothing ties it to Node.js. This ports its serve mode — the pinned bus connection and patient connect, following memberships, launching every served bundle over MCP on stdio with its own environment, a child's emit published as its module, each tool, the tools verb and seat verbs served where the mesh issued them, and the console on loopback — to one static binary. Same subjects, request and reply bodies, event headers and MCP answers. The TypeScript stays: it is still the runtime inside the per-module containers until WP4c. Tests run against a real bus and share the TypeScript fixtures.
252 lines
9.6 KiB
Go
252 lines
9.6 KiB
Go
package runtime
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
mt "github.com/novox/mesh-tools/node-tools/internal/meshtest"
|
|
)
|
|
|
|
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 call(t *testing.T, asker *bus.Conn, key string, args any) (string, error) {
|
|
t.Helper()
|
|
got, err := asker.Ask(key, args, "")
|
|
return string(got.Result), err
|
|
}
|
|
|
|
func same(t *testing.T, got, want string) {
|
|
t.Helper()
|
|
var a, b any
|
|
if json.Unmarshal([]byte(got), &a) != nil || json.Unmarshal([]byte(want), &b) != nil {
|
|
t.Fatalf("not JSON: got %s want %s", got, want)
|
|
}
|
|
ga, _ := json.Marshal(a)
|
|
gb, _ := json.Marshal(b)
|
|
if string(ga) != string(gb) {
|
|
t.Errorf("got %s, want %s", got, want)
|
|
}
|
|
}
|
|
|
|
// The node's runtime serves five modules' bundles on one credential — two TypeScript, one broken,
|
|
// one Python, one written against the protocol — and follows a re-issued membership (ADR 0175, 0193).
|
|
func TestTheNodesRuntimeServesFiveModulesAndFollowsAReissuedMembership(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", true, nil))
|
|
mesh.Issue(t, mt.MembershipOf("beta", "anchor", false, map[string][]string{"node-shelf": {"list", "clear"}}))
|
|
mesh.Issue(t, mt.MembershipOf("gamma", "anchor", false, nil))
|
|
mesh.Issue(t, mt.MembershipOf("delta", "anchor", false, map[string][]string{"node-lamp": {"on"}}))
|
|
mesh.Issue(t, mt.MembershipOf("epsilon", "anchor", false, nil))
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
asker := connect(t, "console", "workstation")
|
|
logs := &mt.Logs{}
|
|
t.Setenv("MESH_OPERATOR_ACCOUNT", "somebody")
|
|
t.Setenv("MESH_OPERATOR_HOME", "/home/somebody")
|
|
stop, err := Run(nodeTools, []Served{
|
|
{"alpha", []string{mt.Fixture("many-alpha.serve.mjs")}},
|
|
{"beta", []string{mt.Fixture("many-beta.serve.mjs")}},
|
|
{"gamma", []string{mt.Fixture("many-broken.serve.mjs")}},
|
|
{"delta", []string{mt.Fixture("many-delta.py")}},
|
|
{"epsilon", []string{mt.Fixture("many-epsilon.mjs")}},
|
|
}, nil, logs.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
if !logs.Has("the operator's account here is somebody (home /home/somebody)") {
|
|
t.Errorf("the operator was not said:\n%s", logs.All())
|
|
}
|
|
if !logs.Has("gamma's bundle", "many-broken.serve.mjs failed to load: gamma's bundle exited (1): Error: gamma's bundle cannot find its client; its tools are not served here") {
|
|
t.Errorf("the broken bundle was not named with its own words:\n%s", logs.All())
|
|
}
|
|
if !logs.Has("serving 8 tool(s) for 5 module(s): alpha.one, alpha.two, beta.three, beta.four, beta.five, delta.greet, delta.die, epsilon.seven; not serving gamma") {
|
|
t.Errorf("not serving what it should:\n%s", logs.All())
|
|
}
|
|
|
|
for key, want := range map[string]string{
|
|
"alpha.one": `{"alpha":1}`, "alpha.one@anchor": `{"alpha":1}`, "beta.three@anchor": `{"beta":3}`,
|
|
"beta.four@anchor": `{"beta":4}`, "beta.five@anchor": `{"beta":5}`,
|
|
"seat:node-shelf.list@anchor": `{"shelf":["a","b"]}`, "seat:node-shelf.clear@anchor": `{"cleared":true}`,
|
|
"seat:node-lamp.on@anchor": `{"on":true,"language":"python"}`, "epsilon.seven@anchor": `{"epsilon":7,"via":"stdio"}`,
|
|
} {
|
|
got, err := call(t, asker, key, map[string]any{})
|
|
if err != nil {
|
|
t.Errorf("%s: %v", key, err)
|
|
continue
|
|
}
|
|
same(t, got, want)
|
|
}
|
|
if _, err := call(t, asker, "beta.three", map[string]any{}); err == nil || !strings.Contains(err.Error(), "no responders") {
|
|
t.Errorf("beta was not issued the plain subject, and answered on it: %v", err)
|
|
}
|
|
got, err := call(t, asker, "delta.greet@anchor", map[string]any{"who": "mesh"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"greeting":"hello mesh","language":"python"}`)
|
|
|
|
// A launched tool that emits does so as its module, through the runtime.
|
|
landed := mesh.NextEvent(t, "mesh.mod.*.event.>")
|
|
got, err = call(t, asker, "alpha.two", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"alpha":2}`)
|
|
select {
|
|
case subject := <-landed:
|
|
if subject != "mesh.mod.alpha.event.happened" {
|
|
t.Errorf("the event landed on %s", subject)
|
|
}
|
|
case <-time.After(5 * time.Second):
|
|
t.Error("the event never landed")
|
|
}
|
|
|
|
// A bundle that dies mid-call is said, and started again on its next call.
|
|
if _, err := call(t, asker, "delta.die@anchor", map[string]any{}); err == nil {
|
|
t.Error("a bundle that died answered")
|
|
}
|
|
got, err = call(t, asker, "delta.greet@anchor", map[string]any{"who": "again"})
|
|
if err != nil {
|
|
t.Fatalf("not started again: %v", err)
|
|
}
|
|
same(t, got, `{"greeting":"hello again","language":"python"}`)
|
|
|
|
// `tools` answers for each, and why gamma serves nothing.
|
|
gamma, err := call(t, asker, "gamma.tools@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, gamma, `{"module":"gamma","tools":[],"failed":"gamma's bundle exited (1): Error: gamma's bundle cannot find its client"}`)
|
|
beta, _ := call(t, asker, "beta.tools@anchor", map[string]any{})
|
|
var answer ToolsAnswer
|
|
_ = json.Unmarshal([]byte(beta), &answer)
|
|
if len(answer.Tools) != 3 || answer.Tools[0].Name != "three" || strings.Join(answer.Tools[0].Subjects, ",") != "mesh.mod.beta.tool.three.anchor" {
|
|
t.Errorf("beta's tools answer: %s", beta)
|
|
}
|
|
|
|
// Re-issued mid-run, now answering for the module anywhere: served without a restart.
|
|
mesh.Issue(t, mt.MembershipOf("beta", "anchor", true, map[string][]string{"node-shelf": {"list", "clear"}}))
|
|
mt.Until(t, func() error { _, err := call(t, asker, "beta.three", map[string]any{}); return err })
|
|
got, err = call(t, asker, "seat:node-shelf.list@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"shelf":["a","b"]}`)
|
|
}
|
|
|
|
// Each bundle is given its own environment and none of another's (ADR 0192), and the composed
|
|
// environments are not left in the runtime's.
|
|
func TestEachBundleIsGivenItsOwnEnvironment(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
for _, m := range []string{"gamma", "delta", "zeta"} {
|
|
mesh.Issue(t, mt.MembershipOf(m, "anchor", false, nil))
|
|
}
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
asker := connect(t, "console", "workstation")
|
|
t.Setenv("MESH_OPERATOR_ACCOUNT", "somebody")
|
|
t.Setenv(ToolEnv, `{"gamma":{"GAMMA_CONFIG_FILE":"/var/lib/mesh/gamma/config.json"},"delta":{"DELTA_TOKEN_FILE":"/var/lib/mesh/delta/token"},"zeta":{"ZETA_URL":"http://127.0.0.1:3000"}}`)
|
|
envs, err := TakeToolEnvs()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, left := os.LookupEnv(ToolEnv); left {
|
|
t.Error("the composed environments were left in the runtime's")
|
|
}
|
|
logs := &mt.Logs{}
|
|
stop, err := Run(nodeTools, []Served{
|
|
{"gamma", []string{mt.Fixture("env-gamma.serve.mjs")}},
|
|
{"delta", []string{mt.Fixture("env-delta.serve.mjs")}},
|
|
{"zeta", []string{mt.Fixture("env-zeta.mjs")}},
|
|
}, envs, logs.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
for key, want := range map[string]string{
|
|
"gamma.given@anchor": `{"mine":"/var/lib/mesh/gamma/config.json","theirs":null,"runtime":"somebody","composed":null}`,
|
|
"delta.given@anchor": `{"mine":"/var/lib/mesh/delta/token","theirs":null}`,
|
|
"zeta.given@anchor": `{"mine":"http://127.0.0.1:3000","theirs":null,"composed":null}`,
|
|
} {
|
|
got, err := call(t, asker, key, map[string]any{})
|
|
if err != nil {
|
|
t.Fatalf("%s: %v\n%s", key, err, logs.All())
|
|
}
|
|
same(t, got, want)
|
|
}
|
|
}
|
|
|
|
// An entrypoint that is not executable is refused by name, and the others serve (ADR 0193).
|
|
func TestAnEntrypointThatIsNotExecutableIsRefused(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil))
|
|
mesh.Issue(t, mt.MembershipOf("plain", "anchor", false, nil))
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
asker := connect(t, "console", "workstation")
|
|
logs := &mt.Logs{}
|
|
stop, err := Run(nodeTools, []Served{
|
|
{"alpha", []string{mt.Fixture("many-alpha.serve.mjs")}},
|
|
{"plain", []string{mt.Fixture("many-alpha.mjs")}},
|
|
}, nil, logs.Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
if !logs.Has("plain's bundle", "many-alpha.mjs failed to load:", "is not executable; a bundle the runtime serves is started, never imported") {
|
|
t.Errorf("the non-executable entrypoint was not refused by name:\n%s", logs.All())
|
|
}
|
|
got, err := call(t, asker, "alpha.one@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"alpha":1}`)
|
|
}
|
|
|
|
// A launched bundle is told the module it serves, so its seat's verbs stay the seat's (ADR 0193).
|
|
func TestALaunchedBundleRegisteringItsSeatFirstServesTheSeat(t *testing.T) {
|
|
mesh := mt.New(t)
|
|
mesh.Issue(t, mt.MembershipOf("theta", "anchor", false, map[string][]string{"node-shelf": {"list"}}))
|
|
nodeTools := connect(t, "node-tools", "anchor")
|
|
asker := connect(t, "console", "workstation")
|
|
stop, err := Run(nodeTools, []Served{{"theta", []string{mt.Fixture("served-seat-first.mjs")}}},
|
|
map[string]map[string]string{"theta": {"THETA_WORD": "given"}}, (&mt.Logs{}).Logf)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer stop()
|
|
got, err := call(t, asker, "theta.own@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"theta":"given"}`)
|
|
got, err = call(t, asker, "seat:node-shelf.list@anchor", map[string]any{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
same(t, got, `{"shelf":["x"]}`)
|
|
}
|
|
|
|
func TestToolModulesNamesOtherModulesOnly(t *testing.T) {
|
|
got, err := ServedModulesFrom(" alpha=/a/tools/index.serve.mjs, beta=/b/one, beta=/b/two ", "node-tools")
|
|
if err != nil || len(got) != 2 || got[1].Module != "beta" || len(got[1].Entrypoints) != 2 {
|
|
t.Fatalf("%v %v", got, err)
|
|
}
|
|
for _, bad := range []string{"/mine/index.js", "node-tools=/own.js", "=/x"} {
|
|
if _, err := ServedModulesFrom(bad, "node-tools"); err == nil {
|
|
t.Errorf("%q was accepted", bad)
|
|
}
|
|
}
|
|
}
|