Cthulhu is no longer a fork. Now it is a screen reader written in Golang.
This commit is contained in:
1 parent
a998fe67de
commit
069fc674dc
708 files changed
+112482
-532
No files matched your search
@@ -0,0 +1,252 @@
|
||||
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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user