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.
144 lines
3.7 KiB
Go
144 lines
3.7 KiB
Go
// Package meshtest raises what the controller would, for tests against a real bus: the ASSIGNMENTS
|
|
// and EVENTS streams, memberships issued by hand, and a fixture's path.
|
|
package meshtest
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/nats-io/nats.go"
|
|
|
|
"github.com/novox/mesh-tools/node-tools/internal/bus"
|
|
)
|
|
|
|
// URL is the test bus, or the test is skipped.
|
|
func URL(t *testing.T) string {
|
|
t.Helper()
|
|
url := os.Getenv("MESH_TEST_NATS")
|
|
if url == "" {
|
|
t.Skip("MESH_TEST_NATS unset")
|
|
}
|
|
return url
|
|
}
|
|
|
|
// Mesh is the controller's job, done by hand.
|
|
type Mesh struct {
|
|
nc *nats.Conn
|
|
js nats.JetStreamContext
|
|
}
|
|
|
|
// New raises the streams afresh.
|
|
func New(t *testing.T) *Mesh {
|
|
t.Helper()
|
|
nc, err := nats.Connect(URL(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
js, _ := nc.JetStream()
|
|
for _, s := range []string{"ASSIGNMENTS", "EVENTS"} {
|
|
_ = js.DeleteStream(s)
|
|
}
|
|
if _, err := js.AddStream(&nats.StreamConfig{Name: "ASSIGNMENTS", Subjects: []string{"mesh.assignment.>"},
|
|
MaxMsgsPerSubject: 1, AllowDirect: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := js.AddStream(&nats.StreamConfig{Name: "EVENTS", Subjects: []string{"mesh.mod.*.event.>"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(nc.Close)
|
|
return &Mesh{nc: nc, js: js}
|
|
}
|
|
|
|
// Issue publishes a membership.
|
|
func (m *Mesh) Issue(t *testing.T, mem bus.Membership) {
|
|
t.Helper()
|
|
body, _ := json.Marshal(mem)
|
|
if _, err := m.js.Publish(bus.MembershipSubject(mem.Node, mem.Module), body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// NextEvent is the subject the next event under a pattern lands on.
|
|
func (m *Mesh) NextEvent(t *testing.T, pattern string) <-chan string {
|
|
t.Helper()
|
|
ch := make(chan string, 1)
|
|
sub, err := m.nc.Subscribe(pattern, func(msg *nats.Msg) {
|
|
select {
|
|
case ch <- msg.Subject:
|
|
default:
|
|
}
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
|
_ = m.nc.Flush()
|
|
return ch
|
|
}
|
|
|
|
// MembershipOf is a membership as the controller issues one on a machine.
|
|
func MembershipOf(module, node string, plain bool, seats map[string][]string) bus.Membership {
|
|
own := "mesh.mod." + module
|
|
serves := []bus.Served{{Subject: own + ".tool.{tool}." + node}}
|
|
if plain {
|
|
serves = append(serves, bus.Served{Subject: own + ".tool.{tool}", Queue: "serve." + module})
|
|
}
|
|
var verbs []bus.SeatVerb
|
|
for seat, vs := range seats {
|
|
for _, v := range vs {
|
|
verbs = append(verbs, bus.SeatVerb{Seat: seat, Verb: v, Subject: "mesh.seat." + seat + ".tool." + v + "." + node})
|
|
}
|
|
}
|
|
return bus.Membership{Node: node, Module: module, Serves: serves, Seats: verbs,
|
|
Emits: own + ".event.{event}", Tools: own + ".tool.tools"}
|
|
}
|
|
|
|
// Fixture is a path in node-tools/test/fixtures, which these tests share with the TypeScript ones.
|
|
func Fixture(name string) string {
|
|
_, here, _, _ := runtime.Caller(0)
|
|
return filepath.Join(filepath.Dir(here), "..", "..", "test", "fixtures", name)
|
|
}
|
|
|
|
// Until retries while the bus answers "no responders" — something not yet served.
|
|
func Until(t *testing.T, try func() error) {
|
|
t.Helper()
|
|
var err error
|
|
for i := 0; i < 50; i++ {
|
|
if err = try(); err == nil {
|
|
return
|
|
}
|
|
time.Sleep(100 * time.Millisecond)
|
|
}
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Logs collects what the runtime says.
|
|
type Logs struct{ lines []string }
|
|
|
|
// Logf is a logger that keeps the lines.
|
|
func (l *Logs) Logf(format string, args ...any) {
|
|
l.lines = append(l.lines, sprintf(format, args...))
|
|
}
|
|
|
|
// Has says whether a line contains every fragment.
|
|
func (l *Logs) Has(fragments ...string) bool {
|
|
for _, line := range l.lines {
|
|
all := true
|
|
for _, f := range fragments {
|
|
all = all && strings.Contains(line, f)
|
|
}
|
|
if all {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// All is every line.
|
|
func (l *Logs) All() string { return strings.Join(l.lines, "\n") }
|