Files

253 lines
8.9 KiB
Go

package tentacle
import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/tentaclewire"
sdk "git.stormux.org/storm/cthulhu/sdk/go/tentacle"
)
func TestSDKChild(t *testing.T) {
if len(os.Args) < 3 || os.Args[len(os.Args)-2] != "sdk-child" {
return
}
cancelFile := os.Args[len(os.Args)-1]
started := make(chan struct{})
var once sync.Once
commands := []sdk.Command{
{ID: "echo", Title: "Echo", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { return args, nil }},
{ID: "fail", Title: "Fail", Handle: func(ctx context.Context, args json.RawMessage) (any, error) { panic("private handler details") }},
{ID: "large", Title: "Large", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
return strings.Repeat("x", MaxFrameBytes), nil
}},
{ID: "wait", Title: "Wait", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
once.Do(func() { close(started) })
<-ctx.Done()
return nil, os.WriteFile(cancelFile, []byte("cancelled"), 0600)
}},
{ID: "started", Title: "Started", Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
select {
case <-started:
return true, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}},
}
if filepath.Base(cancelFile) == "presentation" {
commands = append(commands, sdk.Command{ID: "present", Title: "Present", Presentation: true,
Handle: func(ctx context.Context, args json.RawMessage) (any, error) {
return sdk.Presentation{Speech: "Hello, é猫😀é", Braille: "Display é猫😀é", Uninterruptible: true}, nil
}})
}
if sdk.Serve(os.Stdin, os.Stdout, commands) != nil {
os.Exit(1)
}
os.Exit(0)
}
func sdk_program(t *testing.T, language, cancelFile string) []string {
t.Helper()
if language == "go" {
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
return []string{executable, "-test.run=^TestSDKChild$", "--", "sdk-child", cancelFile}
}
python, err := exec.LookPath("python3")
if err != nil {
t.Skip("Python 3 not installed")
}
peer, err := filepath.Abs("testdata/sdk_peer.py")
if err != nil {
t.Fatal(err)
}
return []string{python, "-B", peer, cancelFile}
}
func TestSDKHostConformance(t *testing.T) {
for _, language := range []string{"go", "python"} {
for _, ending := range []string{"disable", "cancel"} {
t.Run(language+"/"+ending, func(t *testing.T) {
cancelFile := filepath.Join(t.TempDir(), "cancelled")
limits := DefaultLimits()
limits.RequestTimeout = 5 * time.Second
manager := test_manager(t, limits, nil)
session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: sdk_program(t, language, cancelFile)})
if err != nil {
t.Fatal(err)
}
wait_signal(t, session.Ready())
if state, err := session.Status(); state != Ready || err != nil {
t.Fatalf("%s: %v", state, err)
}
if _, err := session.Invoke(context.Background(), "echo", nil); !errors.Is(err, ErrUnavailable) {
t.Fatal("preactivation dispatch", err)
}
if err := session.Activate(); err != nil {
t.Fatal(err)
}
invoke := func(command string, args json.RawMessage) Result {
t.Helper()
pending, err := session.Invoke(context.Background(), command, args)
if err != nil {
t.Fatal(err)
}
return wait_result(t, pending)
}
for _, raw := range []string{`{"text":"é猫😀é","nested":[true,null,42]}`, `null`, `[1,"two"]`} {
result := invoke("echo", json.RawMessage(raw))
if result.Err != nil || string(result.Value) != raw || result.Session != session.Identity() {
t.Fatalf("round trip: %+v", result)
}
}
// Fill the default host window repeatedly. Responses can arrive
// out of order; each caller must receive its own arguments.
for round := 0; round < 20; round++ {
replies := make([]<-chan Result, 8)
for index := range replies {
args, _ := json.Marshal(map[string]int{"index": index})
replies[index], err = session.Invoke(context.Background(), "echo", args)
if err != nil {
t.Fatal(err)
}
}
for index, reply := range replies {
result := wait_result(t, reply)
var value struct {
Index int `json:"index"`
}
if result.Err != nil || json.Unmarshal(result.Value, &value) != nil || value.Index != index {
t.Fatalf("correlation: %+v", result)
}
}
}
for _, command := range []string{"fail", "large"} {
result := invoke(command, nil)
var rpcError *RPCError
if !errors.As(result.Err, &rpcError) || rpcError.Code != -32603 || strings.Contains(rpcError.Message, "private") {
t.Fatalf("unsafe error: %+v", result)
}
}
if result := invoke("echo", nil); result.Err != nil {
t.Fatal("session failed after handler error", result.Err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
pending, err := session.Invoke(ctx, "wait", nil)
if err != nil {
t.Fatal(err)
}
if result := invoke("started", nil); result.Err != nil || string(result.Value) != "true" {
t.Fatal("concurrent handler failed", result)
}
if ending == "disable" {
session.Disable()
} else {
cancel()
}
if result := wait_result(t, pending); result.Err == nil {
t.Fatal("late success", result)
}
wait_signal(t, session.Done())
if ending == "disable" {
if data, err := os.ReadFile(cancelFile); err != nil || string(data) != "cancelled" {
t.Fatalf("SDK did not cancel handler: %q, %v", data, err)
}
}
})
}
}
}
func TestSDKPresentationGrantAndRevocation(t *testing.T) {
for _, language := range []string{"go", "python"} {
for _, grant := range []bool{false, true} {
t.Run(language+"/grant="+strconv.FormatBool(grant), func(t *testing.T) {
manager := test_manager(t, DefaultLimits(), nil)
program := sdk_program(t, language, filepath.Join(t.TempDir(), "presentation"))
session, err := manager.Start(context.Background(), Spec{ID: "sdk", Program: program, Presentation: grant})
if err != nil {
t.Fatal(err)
}
wait_signal(t, session.Ready())
state, err := session.Status()
if !grant {
if state != Failed || err == nil {
t.Fatal("ungranted presentation registration succeeded")
}
return
}
if state != Ready || err != nil {
t.Fatalf("%s: %v", state, err)
}
if err := session.Activate(); err != nil {
t.Fatal(err)
}
pending, err := session.Invoke(context.Background(), "present", nil)
if err != nil {
t.Fatal(err)
}
result := wait_result(t, pending)
value, err := tentaclewire.DecodePresentation(result.Value)
if result.Err != nil || err != nil || value.Speech != "Hello, é猫😀é" || value.Braille != "Display é猫😀é" || !value.Uninterruptible {
t.Fatalf("presentation: %+v, %v", result, err)
}
if session.OutputContext().Err() != nil {
t.Fatal("reply completion revoked output")
}
session.Disable()
if session.OutputContext().Err() == nil {
t.Fatal("disable left buffered output live")
}
})
}
}
}
func TestSDKRejectsInvalidHostMessages(t *testing.T) {
initialize := `{"jsonrpc":"2.0","id":"1","method":"initialize","params":{"protocol":{"major":1,"minor":0},"session":"test","apis":["commands"],"capabilities":["commands"]}}` + "\n"
activate := `{"jsonrpc":"2.0","method":"initialized","params":{"session":"test"}}` + "\n"
invoke := `{"jsonrpc":"2.0","id":"2","method":"command.invoke","params":{"command":"echo","arguments":{}}}` + "\n"
cases := map[string]string{
"before activation": initialize + invoke,
"wrong session": initialize + strings.Replace(activate, "test", "other", 1),
"null version": strings.Replace(initialize, `"minor":0`, `"minor":null`, 1),
"null ID": `{"jsonrpc":"2.0","id":null,"method":"shutdown"}` + "\n",
"wrong version": strings.Replace(initialize, `"major":1`, `"major":2`, 1),
"duplicate registration": initialize + initialize,
"duplicate request": initialize + activate + invoke + invoke,
"duplicate key": `{"jsonrpc":"2.0","jsonrpc":"2.0","method":"shutdown"}` + "\n",
"oversize": strings.Repeat("x", MaxFrameBytes+1) + "\n",
"unterminated": strings.TrimSuffix(initialize, "\n"),
}
for _, language := range []string{"go", "python"} {
for name, input := range cases {
t.Run(language+"/"+name, func(t *testing.T) {
program := sdk_program(t, language, filepath.Join(t.TempDir(), "cancelled"))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
command := exec.CommandContext(ctx, program[0], program[1:]...)
command.Stdin = strings.NewReader(input)
if err := command.Run(); err == nil {
t.Fatal("invalid input accepted")
}
if ctx.Err() != nil {
t.Fatal("SDK hung on invalid input")
}
})
}
}
}