A handler takes an event by returning and asks for it again by throwing; any mesh/event answer but an error is taken. Written down in the README where event handling is, with what follows for a handler: throw only when the work was not done, and be safe to run twice. Both SDKs added the handler before asking the runtime and kept it when the runtime refused, so a module that asks again until it is taken (every Go consumer does) ran its handler once per attempt for every event. A refused subscribe now removes its handler; one the Go runtime did not answer in time is kept, since the runtime may have bound it.
227 lines
7.6 KiB
Go
227 lines
7.6 KiB
Go
package stdio
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// A Go bundle serves as the module the runtime names, answers calls concurrently, and emits through
|
|
// the runtime with the TypeScript SDK's envelope (novox/hq ADR 0193).
|
|
func TestABundleEmitsThroughTheRuntimeAndServesAsItsModule(t *testing.T) {
|
|
t.Setenv(ServedAs, "chime")
|
|
t.Setenv("MESH_MODULE", "chime")
|
|
t.Setenv("MESH_NODE", "anchor")
|
|
inR, inW := io.Pipe()
|
|
outR, outW := io.Pipe()
|
|
go func() {
|
|
_ = ServeOn("", []Tool{{Name: "ring", Description: "rings", Run: func(map[string]any) (any, error) {
|
|
if err := Emit("rang", map[string]any{"times": 1}); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{"rang": true}, nil
|
|
}}}, inR, outW)
|
|
}()
|
|
lines := bufio.NewScanner(outR)
|
|
next := func() map[string]any {
|
|
done := make(chan map[string]any, 1)
|
|
go func() {
|
|
if lines.Scan() {
|
|
var m map[string]any
|
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
|
done <- m
|
|
}
|
|
}()
|
|
select {
|
|
case m := <-done:
|
|
return m
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the bundle said nothing")
|
|
return nil
|
|
}
|
|
}
|
|
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
|
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": map[string]any{}})
|
|
if got := next()["result"].(map[string]any)["serverInfo"].(map[string]any)["name"]; got != "chime" {
|
|
t.Fatalf("served as %v, want the module the runtime named", got)
|
|
}
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": map[string]any{"name": "ring", "arguments": map[string]any{}}})
|
|
asked := next()
|
|
if asked["method"] != "mesh/publish" {
|
|
t.Fatalf("the tool's emit was not asked of the runtime: %v", asked)
|
|
}
|
|
p := asked["params"].(map[string]any)
|
|
h := p["headers"].(map[string]any)
|
|
if p["key"] != "rang" || p["node"] != "anchor" || h["x-source"] != "chime" || h["content-type"] != "application/json" || len(h["x-event-id"].(string)) != 36 {
|
|
t.Errorf("the envelope is not the SDK's: %v", p)
|
|
}
|
|
send(map[string]any{"jsonrpc": "2.0", "id": asked["id"], "result": map[string]any{}})
|
|
answered := next()
|
|
text := answered["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"]
|
|
if answered["id"] != float64(2) || text != `{"rang":true}` {
|
|
t.Errorf("the call answered %v", answered)
|
|
}
|
|
_ = inW.Close()
|
|
}
|
|
|
|
// A Go bundle subscribes and asks through the runtime (novox/hq ADR 0198): the event is answered only
|
|
// after the handler took it, a handler's error is answered as one, and a tool call is asked as the module.
|
|
func TestABundleSubscribesAndAsksThroughTheRuntime(t *testing.T) {
|
|
t.Setenv(ServedAs, "watcher")
|
|
inR, inW := io.Pipe()
|
|
outR, outW := io.Pipe()
|
|
ready := make(chan struct{})
|
|
go func() {
|
|
_ = ServeOn("", []Tool{{Name: "start", Run: func(map[string]any) (any, error) {
|
|
go func() {
|
|
_ = Subscribe("database.*", func(e Envelope) error {
|
|
if e.Key == "database.bad" {
|
|
return errors.New("not taken")
|
|
}
|
|
return nil
|
|
})
|
|
close(ready)
|
|
}()
|
|
answer, err := Ask("postgres.list", map[string]any{})
|
|
return json.RawMessage(answer), err
|
|
}}}, inR, outW)
|
|
}()
|
|
lines := bufio.NewScanner(outR)
|
|
next := func() map[string]any {
|
|
done := make(chan map[string]any, 1)
|
|
go func() {
|
|
if lines.Scan() {
|
|
var m map[string]any
|
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
|
done <- m
|
|
}
|
|
}()
|
|
select {
|
|
case m := <-done:
|
|
return m
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the bundle said nothing")
|
|
return nil
|
|
}
|
|
}
|
|
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "start"}})
|
|
// Two asks arrive, in either order: the subscription and the tool call.
|
|
asked := map[string]map[string]any{}
|
|
for len(asked) < 2 {
|
|
m := next()
|
|
asked[m["method"].(string)] = m
|
|
}
|
|
sub, ask := asked["mesh/subscribe"], asked["mesh/ask"]
|
|
if sub["params"].(map[string]any)["pattern"] != "database.*" || ask["params"].(map[string]any)["key"] != "postgres.list" {
|
|
t.Fatalf("asked %v", asked)
|
|
}
|
|
send(map[string]any{"jsonrpc": "2.0", "id": sub["id"], "result": map[string]any{}})
|
|
send(map[string]any{"jsonrpc": "2.0", "id": ask["id"], "result": []string{"a", "b"}})
|
|
if r := next(); r["id"] != float64(1) || r["result"].(map[string]any)["content"].([]any)[0].(map[string]any)["text"] != `["a","b"]` {
|
|
t.Fatalf("the call answered %v", r)
|
|
}
|
|
<-ready
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 7, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{"key": "database.provisioned", "node": "anchor", "body": map[string]any{}}}})
|
|
if r := next(); r["id"] != float64(7) || r["error"] != nil {
|
|
t.Errorf("a taken event was answered %v", r)
|
|
}
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 8, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{"key": "database.bad", "node": "anchor", "body": map[string]any{}}}})
|
|
if r := next(); r["id"] != float64(8) || r["error"] == nil {
|
|
t.Errorf("a refused event was answered %v, want an error so the runtime does not acknowledge it", r)
|
|
}
|
|
_ = inW.Close()
|
|
if !TopicMatches("a.#", "a.b.c") || TopicMatches("a.*", "a.b.c") || !TopicMatches("#", "x") {
|
|
t.Error("topic matching differs from the TypeScript SDK's")
|
|
}
|
|
}
|
|
|
|
// A subscribe the runtime refused leaves no handler behind: asked again until the runtime takes it, a
|
|
// module's handler is handed each event once, not once per attempt (novox/hq issue 276).
|
|
func TestARefusedSubscribeLeavesNoHandlerBehind(t *testing.T) {
|
|
t.Setenv(ServedAs, "plex")
|
|
inR, inW := io.Pipe()
|
|
outR, outW := io.Pipe()
|
|
var mu sync.Mutex
|
|
handled := 0
|
|
subscribed := make(chan error, 2)
|
|
go func() {
|
|
_ = ServeOn("", []Tool{{Name: "start", Run: func(map[string]any) (any, error) {
|
|
go func() {
|
|
for i := 0; i < 2; i++ {
|
|
err := Subscribe("*.download.completed", func(Envelope) error {
|
|
mu.Lock()
|
|
handled++
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
subscribed <- err
|
|
if err == nil {
|
|
return
|
|
}
|
|
}
|
|
}()
|
|
return "started", nil
|
|
}}}, inR, outW)
|
|
}()
|
|
lines := bufio.NewScanner(outR)
|
|
next := func() map[string]any {
|
|
done := make(chan map[string]any, 1)
|
|
go func() {
|
|
if lines.Scan() {
|
|
var m map[string]any
|
|
_ = json.Unmarshal(lines.Bytes(), &m)
|
|
done <- m
|
|
}
|
|
}()
|
|
select {
|
|
case m := <-done:
|
|
return m
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("the bundle said nothing")
|
|
return nil
|
|
}
|
|
}
|
|
send := func(m map[string]any) { b, _ := json.Marshal(m); _, _ = inW.Write(append(b, '\n')) }
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": map[string]any{"name": "start"}})
|
|
answered := 0
|
|
for answered < 2 {
|
|
m := next()
|
|
switch m["method"] {
|
|
case "mesh/subscribe":
|
|
if answered == 0 {
|
|
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "error": map[string]any{"code": -32000, "message": "not yet"}})
|
|
} else {
|
|
send(map[string]any{"jsonrpc": "2.0", "id": m["id"], "result": map[string]any{}})
|
|
}
|
|
answered++
|
|
}
|
|
}
|
|
if err := <-subscribed; err == nil || err.Error() != "not yet" {
|
|
t.Fatalf("the refused subscribe said %v", err)
|
|
}
|
|
if err := <-subscribed; err != nil {
|
|
t.Fatalf("the second subscribe said %v", err)
|
|
}
|
|
send(map[string]any{"jsonrpc": "2.0", "id": 9, "method": "mesh/event", "params": map[string]any{"envelope": map[string]any{"key": "radarr.download.completed", "node": "anchor", "body": map[string]any{}}}})
|
|
for {
|
|
if r := next(); r["id"] == float64(9) {
|
|
if r["error"] != nil {
|
|
t.Fatalf("the event was answered %v", r)
|
|
}
|
|
break
|
|
}
|
|
}
|
|
_ = inW.Close()
|
|
mu.Lock()
|
|
defer mu.Unlock()
|
|
if handled != 1 {
|
|
t.Fatalf("one event was handled %d times", handled)
|
|
}
|
|
}
|