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") } }) } } }