Cthulhu is no longer a fork. Now it is a screen reader written in Golang.

This commit is contained in:
Storm Dragon committed 2026-10-14 00:16:34 -04:00
1 parent a998fe67de
commit 069fc674dc
708 files changed
+112482 -532

No files matched your search

+210
View File
@@ -0,0 +1,210 @@
// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA
// compatibility bridges and routes it to Cthulhu's speech and braille queues.
package nvda2cthulhu
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"math"
)
type messagePackBinary string
func decodeMessagePack(reader *bytes.Reader) (any, error) {
return decodeMessagePackDepth(reader, 0)
}
func decodeMessagePackDepth(reader *bytes.Reader, depth int) (any, error) {
if depth > 64 {
return nil, errors.New("MessagePack nesting exceeds limit")
}
code, err := reader.ReadByte()
if err != nil {
return nil, err
}
switch {
case code <= 0x7f:
return int64(code), nil
case code >= 0xe0:
return int64(int8(code)), nil
case code >= 0x80 && code <= 0x8f:
return decodeMessagePackMap(reader, int(code&0x0f), depth)
case code >= 0x90 && code <= 0x9f:
return decodeMessagePackArray(reader, int(code&0x0f), depth)
case code >= 0xa0 && code <= 0xbf:
return decodeMessagePackString(reader, int(code&0x1f))
case code == 0xc0:
return nil, nil
case code == 0xc2:
return false, nil
case code == 0xc3:
return true, nil
case code == 0xc4 || code == 0xc5 || code == 0xc6:
length, err := decodeLength(reader, code, 0xc4)
if err != nil {
return nil, err
}
value := make([]byte, length)
if _, err := io.ReadFull(reader, value); err != nil {
return nil, err
}
return messagePackBinary(value), nil
case code >= 0xca && code <= 0xcb:
return decodeMessagePackFloat(reader, code)
case code >= 0xcc && code <= 0xd3:
return decodeMessagePackInteger(reader, code)
case code == 0xd9 || code == 0xda || code == 0xdb:
length, err := decodeLength(reader, code, 0xd9)
if err != nil {
return nil, err
}
return decodeMessagePackString(reader, length)
case code == 0xdc || code == 0xdd:
length, err := decodeLength(reader, code, 0xdc)
if err != nil {
return nil, err
}
return decodeMessagePackArray(reader, length, depth)
case code == 0xde || code == 0xdf:
length, err := decodeLength(reader, code, 0xde)
if err != nil {
return nil, err
}
return decodeMessagePackMap(reader, length, depth)
default:
return nil, fmt.Errorf("unsupported MessagePack type 0x%02x", code)
}
}
func decodeLength(reader *bytes.Reader, code, base byte) (int, error) {
var size int
switch code - base {
case 0:
size = 1
case 1:
size = 2
case 2:
size = 4
default:
return 0, fmt.Errorf("invalid length type 0x%02x", code)
}
buffer := make([]byte, size)
if _, err := io.ReadFull(reader, buffer); err != nil {
return 0, err
}
value := uint64(0)
for _, octet := range buffer {
value = value<<8 | uint64(octet)
}
if value > uint64(maxPayload) {
return 0, errors.New("MessagePack value exceeds 64 KiB")
}
return int(value), nil
}
func decodeMessagePackString(reader *bytes.Reader, length int) (string, error) {
value := make([]byte, length)
if _, err := io.ReadFull(reader, value); err != nil {
return "", err
}
return string(value), nil
}
func decodeMessagePackFloat(reader *bytes.Reader, code byte) (any, error) {
var buffer []byte
if code == 0xca {
buffer = make([]byte, 4)
} else {
buffer = make([]byte, 8)
}
if _, err := io.ReadFull(reader, buffer); err != nil {
return nil, err
}
if code == 0xca {
bits := binary.BigEndian.Uint32(buffer)
return float64(math.Float32frombits(bits)), nil
}
return math.Float64frombits(binary.BigEndian.Uint64(buffer)), nil
}
func decodeMessagePackInteger(reader *bytes.Reader, code byte) (any, error) {
switch code {
case 0xcc, 0xd0:
buffer := make([]byte, 1)
if _, err := io.ReadFull(reader, buffer); err != nil {
return nil, err
}
if code == 0xcc {
return uint64(buffer[0]), nil
}
return int64(int8(buffer[0])), nil
case 0xcd, 0xd1:
buffer := make([]byte, 2)
if _, err := io.ReadFull(reader, buffer); err != nil {
return nil, err
}
value := binary.BigEndian.Uint16(buffer)
if code == 0xcd {
return uint64(value), nil
}
return int64(int16(value)), nil
case 0xce, 0xd2:
buffer := make([]byte, 4)
if _, err := io.ReadFull(reader, buffer); err != nil {
return nil, err
}
value := binary.BigEndian.Uint32(buffer)
if code == 0xce {
return uint64(value), nil
}
return int64(int32(value)), nil
case 0xcf, 0xd3:
buffer := make([]byte, 8)
if _, err := io.ReadFull(reader, buffer); err != nil {
return nil, err
}
value := binary.BigEndian.Uint64(buffer)
if code == 0xcf {
return value, nil
}
return int64(value), nil
default:
return nil, fmt.Errorf("invalid integer type 0x%02x", code)
}
}
func decodeMessagePackArray(reader *bytes.Reader, length, depth int) ([]any, error) {
values := make([]any, length)
for index := range values {
value, err := decodeMessagePackDepth(reader, depth+1)
if err != nil {
return nil, err
}
values[index] = value
}
return values, nil
}
func decodeMessagePackMap(reader *bytes.Reader, length, depth int) (map[any]any, error) {
values := make(map[any]any, length)
for range length {
key, err := decodeMessagePackDepth(reader, depth+1)
if err != nil {
return nil, err
}
switch key.(type) {
case string, messagePackBinary:
default:
return nil, errors.New("MessagePack map key must be text")
}
value, err := decodeMessagePackDepth(reader, depth+1)
if err != nil {
return nil, err
}
values[key] = value
}
return values, nil
}
+44
View File
@@ -0,0 +1,44 @@
package nvda2cthulhu
import (
"bytes"
"testing"
"github.com/gorilla/websocket"
)
func TestDecodeMessagePackRequests(t *testing.T) {
tests := []struct {
name string
payload []byte
want bridgeRequest
}{
{name: "fix string", payload: []byte{0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}},
{name: "map with string", payload: []byte{0x81, 0xa9, 'S', 'p', 'e', 'a', 'k', 'T', 'e', 'x', 't', 0xa4, 'G', 'a', 'm', 'e'}, want: bridgeRequest{kind: "SpeakText", text: "Game"}},
{name: "array cancel", payload: []byte{0x91, 0xac, 'C', 'a', 'n', 'c', 'e', 'l', 'S', 'p', 'e', 'e', 'c', 'h'}, want: bridgeRequest{kind: "CancelSpeech"}},
{name: "bin value", payload: []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xc4, 0x02, 'O', 'K'}, want: bridgeRequest{kind: "BrailleText", text: "OK"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := decodeRequest(websocket.BinaryMessage, test.payload)
if err != nil {
t.Fatalf("decodeRequest() error = %v", err)
}
if got.kind != test.want.kind || got.text != test.want.text {
t.Fatalf("decodeRequest() = %+v, want %+v", got, test.want)
}
})
}
}
func TestDecodeMessagePackRejectsMalformedContainers(t *testing.T) {
for _, payload := range [][]byte{
{0x81, 0x90, 0xc0}, // An array key cannot be inserted into a Go map.
append(append([]byte{}, bytes.Repeat([]byte{0x91}, 66)...), 0xc0),
{0xa2, 'O', 'K', 0xc0}, // One WebSocket frame contains exactly one request.
} {
if _, err := decodeRequest(websocket.BinaryMessage, payload); err == nil {
t.Fatalf("accepted malformed MessagePack payload %x", payload)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA
// compatibility bridges and routes it to Cthulhu's speech and braille queues.
package nvda2cthulhu
import (
"bytes"
"errors"
"strconv"
"strings"
"github.com/gorilla/websocket"
)
func (server *Server) deliver(request bridgeRequest) {
switch request.kind {
case "SpeakText":
if server.handler.SpeakText(request.text, true) {
server.logger.Debug("NVDA to Cthulhu speech accepted", "bytes", len(request.text))
} else {
server.logger.Warn("NVDA to Cthulhu speech rejected", "bytes", len(request.text))
}
case "BrailleText":
if server.handler.BrailleMessage(request.text) {
server.logger.Debug("NVDA to Cthulhu braille accepted", "bytes", len(request.text))
} else {
server.logger.Debug("NVDA to Cthulhu braille unavailable")
}
case "CancelSpeech":
if server.handler.CancelSpeech() {
server.logger.Debug("NVDA to Cthulhu cancellation accepted")
} else {
server.logger.Warn("NVDA to Cthulhu cancellation rejected")
}
}
}
type bridgeRequest struct {
kind, text string
}
func decodeRequest(messageType int, payload []byte) (bridgeRequest, error) {
if len(payload) > maxPayload {
return bridgeRequest{}, errors.New("message exceeds 64 KiB")
}
switch messageType {
case websocket.TextMessage:
return parseRequest(string(payload))
case websocket.BinaryMessage:
reader := bytes.NewReader(payload)
value, err := decodeMessagePack(reader)
if err != nil {
return bridgeRequest{}, err
}
if reader.Len() != 0 {
return bridgeRequest{}, errors.New("trailing MessagePack data")
}
return parseRequest(value)
default:
return bridgeRequest{}, errors.New("unsupported WebSocket message type")
}
}
func parseRequest(value any) (bridgeRequest, error) {
switch payload := value.(type) {
case string:
if strings.TrimSpace(payload) == "" {
return bridgeRequest{}, errors.New("blank text request")
}
if payload == "CancelSpeech" {
return bridgeRequest{kind: "CancelSpeech"}, nil
}
return bridgeRequest{kind: "SpeakText", text: payload}, nil
case map[any]any:
if len(payload) != 1 {
return bridgeRequest{}, errors.New("map request must contain exactly one entry")
}
for key, value := range payload {
return keyedRequest(coerceText(key), value)
}
case map[string]any:
if len(payload) != 1 {
return bridgeRequest{}, errors.New("map request must contain exactly one entry")
}
for key, value := range payload {
return keyedRequest(key, value)
}
case []any:
if len(payload) == 0 {
return bridgeRequest{}, errors.New("array request is empty")
}
kind := coerceText(payload[0])
if kind == "CancelSpeech" {
return bridgeRequest{kind: "CancelSpeech"}, nil
}
if len(payload) < 2 {
return bridgeRequest{}, errors.New("array request has no value")
}
return keyedRequest(kind, payload[1])
}
return bridgeRequest{}, errors.New("unsupported MessagePack payload")
}
func keyedRequest(kind string, value any) (bridgeRequest, error) {
switch kind {
case "CancelSpeech":
return bridgeRequest{kind: "CancelSpeech"}, nil
case "SpeakText", "BrailleText":
if strings.TrimSpace(coerceText(value)) == "" {
return bridgeRequest{}, errors.New("blank text request")
}
return bridgeRequest{kind: kind, text: coerceText(value)}, nil
default:
return bridgeRequest{}, errors.New("unsupported request kind")
}
}
func coerceText(value any) string {
switch text := value.(type) {
case string:
return text
case messagePackBinary:
return string(text)
case nil:
return ""
case bool:
return strconv.FormatBool(text)
case int:
return strconv.Itoa(text)
case int8:
return strconv.FormatInt(int64(text), 10)
case int16:
return strconv.FormatInt(int64(text), 10)
case int32:
return strconv.FormatInt(int64(text), 10)
case int64:
return strconv.FormatInt(text, 10)
case uint:
return strconv.FormatUint(uint64(text), 10)
case uint8:
return strconv.FormatUint(uint64(text), 10)
case uint16:
return strconv.FormatUint(uint64(text), 10)
case uint32:
return strconv.FormatUint(uint64(text), 10)
case uint64:
return strconv.FormatUint(text, 10)
case float32:
return strconv.FormatFloat(float64(text), 'g', -1, 32)
case float64:
return strconv.FormatFloat(text, 'g', -1, 64)
default:
return ""
}
}
+40
View File
@@ -0,0 +1,40 @@
package nvda2cthulhu
import (
"testing"
)
func TestParseRequestMatchesPythonCompatibilityProtocol(t *testing.T) {
tests := []struct {
name string
value any
want bridgeRequest
}{
{name: "text", value: "Game text", want: bridgeRequest{kind: "SpeakText", text: "Game text"}},
{name: "cancel string", value: "CancelSpeech", want: bridgeRequest{kind: "CancelSpeech"}},
{name: "speech map", value: map[any]any{"SpeakText": "Map text"}, want: bridgeRequest{kind: "SpeakText", text: "Map text"}},
{name: "braille map", value: map[string]any{"BrailleText": "Braille"}, want: bridgeRequest{kind: "BrailleText", text: "Braille"}},
{name: "cancel map", value: map[any]any{"CancelSpeech": nil}, want: bridgeRequest{kind: "CancelSpeech"}},
{name: "speech array", value: []any{"SpeakText", "Array text"}, want: bridgeRequest{kind: "SpeakText", text: "Array text"}},
{name: "cancel array", value: []any{"CancelSpeech"}, want: bridgeRequest{kind: "CancelSpeech"}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := parseRequest(test.value)
if err != nil {
t.Fatalf("parseRequest() error = %v", err)
}
if got.kind != test.want.kind || got.text != test.want.text {
t.Fatalf("parseRequest() = %+v, want %+v", got, test.want)
}
})
}
}
func TestParseRequestRejectsUnknownAndMalformedPayloads(t *testing.T) {
for _, value := range []any{"", map[any]any{}, map[any]any{"Two": "values", "SpeakText": "text"}, []any{}, []any{"Unknown", "value"}} {
if request, err := parseRequest(value); err == nil {
t.Fatalf("parseRequest(%#v) = %+v, want error", value, request)
}
}
}
+196
View File
@@ -0,0 +1,196 @@
// Package nvda2cthulhu accepts the local WebSocket protocol used by NVDA
// compatibility bridges and routes it to Cthulhu's speech and braille queues.
package nvda2cthulhu
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/url"
"os"
"strconv"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
defaultPort = 3457
hostEnv = "NVDA2SPEECHD_HOST"
maxPayload = 64 * 1024
)
// Handler accepts already-decoded bridge requests. Implementations must
// enqueue backend work instead of waiting for speech or braille output.
type Handler interface {
SpeakText(string, bool) bool
BrailleMessage(string) bool
CancelSpeech() bool
}
// Server owns the local bridge listener and active WebSocket connections.
type Server struct {
logger *slog.Logger
handler Handler
connections map[*websocket.Conn]struct{}
connectionWait sync.WaitGroup
stopOnce sync.Once
stopped chan struct{}
mutex sync.Mutex
server *http.Server
}
// New creates a bridge server. A nil logger discards diagnostics.
func New(logger *slog.Logger, handler Handler) *Server {
if logger == nil {
logger = slog.New(slog.DiscardHandler)
}
return &Server{
logger: logger,
handler: handler,
connections: make(map[*websocket.Conn]struct{}),
stopped: make(chan struct{}),
}
}
// Start binds the same local address used by Python Cthulhu. The listener is
// established before Start returns, while connection handling stays async.
func (server *Server) Start(ctx context.Context) error {
listener, err := net.Listen("tcp", bindAddress())
if err != nil {
return fmt.Errorf("bind NVDA to Cthulhu bridge: %w", err)
}
httpServer := &http.Server{
Handler: http.HandlerFunc(server.handleHTTP),
ReadHeaderTimeout: 5 * time.Second,
}
server.mutex.Lock()
server.server = httpServer
server.mutex.Unlock()
go func() {
if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
server.logger.Warn("NVDA to Cthulhu server stopped", "error", err)
}
}()
server.logger.Info("NVDA to Cthulhu listening", "address", bindAddress())
return nil
}
// Stop closes the listener and active connections.
func (server *Server) Stop(ctx context.Context) error {
server.stopOnce.Do(func() {
close(server.stopped)
})
server.mutex.Lock()
httpServer := server.server
connections := make([]*websocket.Conn, 0, len(server.connections))
for connection := range server.connections {
connections = append(connections, connection)
}
server.mutex.Unlock()
for _, connection := range connections {
_ = connection.Close()
}
if httpServer == nil {
return server.waitConnections(ctx)
}
if err := httpServer.Shutdown(ctx); err != nil && !errors.Is(err, context.Canceled) {
return fmt.Errorf("stop NVDA to Cthulhu bridge: %w", err)
}
return server.waitConnections(ctx)
}
func (server *Server) handleHTTP(response http.ResponseWriter, request *http.Request) {
if request.URL.Path != "/" {
http.NotFound(response, request)
return
}
upgrader := websocket.Upgrader{
CheckOrigin: allowedOrigin,
}
connection, err := upgrader.Upgrade(response, request, nil)
if err != nil {
server.logger.Warn("NVDA to Cthulhu connection rejected", "error", err)
return
}
defer connection.Close()
server.mutex.Lock()
select {
case <-server.stopped:
server.mutex.Unlock()
return
default:
server.connectionWait.Add(1)
server.connections[connection] = struct{}{}
server.mutex.Unlock()
}
defer func() {
server.mutex.Lock()
delete(server.connections, connection)
server.mutex.Unlock()
server.connectionWait.Done()
}()
connection.SetReadLimit(maxPayload)
server.logger.Debug("NVDA to Cthulhu client connected", "remote", connection.RemoteAddr().String())
for {
messageType, payload, err := connection.ReadMessage()
if err != nil {
if !websocket.IsCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
server.logger.Debug("NVDA to Cthulhu client disconnected", "error", err)
}
return
}
if len(payload) > maxPayload {
server.logger.Warn("NVDA to Cthulhu message too large", "size", len(payload))
return
}
request, err := decodeRequest(messageType, payload)
if err != nil {
server.logger.Warn("NVDA to Cthulhu rejected message", "error", err)
continue
}
server.deliver(request)
}
}
func (server *Server) waitConnections(ctx context.Context) error {
done := make(chan struct{})
go func() {
server.connectionWait.Wait()
close(done)
}()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
func bindAddress() string {
port := defaultPort
if value := os.Getenv(hostEnv); value != "" {
if parsed, err := url.Parse(value); err == nil && parsed.Port() != "" {
port, _ = strconv.Atoi(parsed.Port())
}
}
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
func allowedOrigin(request *http.Request) bool {
origin := request.Header.Get("Origin")
if origin == "" {
return true // Native bridge clients do not send a browser origin.
}
parsed, err := url.Parse(origin)
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil {
return false
}
host := parsed.Hostname()
return host == "localhost" || net.ParseIP(host).IsLoopback()
}
+171
View File
@@ -0,0 +1,171 @@
package nvda2cthulhu
import (
"context"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
type recordingHandler struct {
mutex sync.Mutex
spoken []string
interrupt []bool
braille []string
canceled int
}
func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.spoken = append(handler.spoken, text)
handler.interrupt = append(handler.interrupt, interrupt)
return true
}
func (handler *recordingHandler) BrailleMessage(text string) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.braille = append(handler.braille, text)
return true
}
func (handler *recordingHandler) CancelSpeech() bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.canceled++
return true
}
func TestAllowedOriginAcceptsNativeAndLoopbackClients(t *testing.T) {
for _, test := range []struct {
origin string
want bool
}{
{"", true},
{"http://127.0.0.1:3457", true},
{"http://localhost:3457", true},
{"https://[::1]:3457", true},
{"https://example.com", false},
{"null", false},
} {
request := httptest.NewRequest(http.MethodGet, "http://127.0.0.1:3457/", nil)
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
if got := allowedOrigin(request); got != test.want {
t.Errorf("allowedOrigin(%q) = %v, want %v", test.origin, got, test.want)
}
}
}
func liveSocketTest(t *testing.T) {
t.Helper()
handler := &recordingHandler{}
server := New(nil, handler)
httpTest := httptest.NewServer(http.HandlerFunc(server.handleHTTP))
defer httpTest.Close()
url := "ws" + strings.TrimPrefix(httpTest.URL, "http")
connection, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatalf("dial WebSocket: %v", err)
}
defer connection.Close()
if err := connection.WriteMessage(websocket.TextMessage, []byte("Accessibility mod loaded.")); err != nil {
t.Fatalf("write speech text: %v", err)
}
if err := connection.WriteMessage(websocket.BinaryMessage, []byte{0x81, 0xab, 'B', 'r', 'a', 'i', 'l', 'l', 'e', 'T', 'e', 'x', 't', 0xa2, 'O', 'K'}); err != nil {
t.Fatalf("write braille text: %v", err)
}
if err := connection.WriteMessage(websocket.TextMessage, []byte("CancelSpeech")); err != nil {
t.Fatalf("write cancellation: %v", err)
}
waitForHandlerCalls(t, handler)
handler.mutex.Lock()
defer handler.mutex.Unlock()
if len(handler.spoken) != 1 || handler.spoken[0] != "Accessibility mod loaded." || len(handler.interrupt) != 1 || !handler.interrupt[0] {
t.Fatalf("speech calls = %v, interrupts = %v", handler.spoken, handler.interrupt)
}
if len(handler.braille) != 1 || handler.braille[0] != "OK" {
t.Fatalf("braille calls = %v", handler.braille)
}
if handler.canceled != 1 {
t.Fatalf("cancellations = %d, want 1", handler.canceled)
}
}
func TestServerHandlesWebSocketRequests(t *testing.T) {
if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" {
t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener")
}
liveSocketTest(t)
}
func TestStartStopClosesActiveConnection(t *testing.T) {
if os.Getenv("CTHULHU_LIVE_SOCKET_TEST") != "1" {
t.Skip("set CTHULHU_LIVE_SOCKET_TEST=1 to allow a local TCP listener")
}
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("find port: %v", err)
}
port := listener.Addr().(*net.TCPAddr).Port
if err := listener.Close(); err != nil {
t.Fatalf("release port: %v", err)
}
t.Setenv(hostEnv, fmt.Sprintf("http://127.0.0.1:%d", port))
server := New(nil, &recordingHandler{})
if err := server.Start(context.Background()); err != nil {
t.Fatalf("start server: %v", err)
}
connection, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d", port), nil)
if err != nil {
t.Fatalf("dial server: %v", err)
}
defer connection.Close()
if err := server.Stop(context.Background()); err != nil {
t.Fatalf("stop server: %v", err)
}
if _, _, err := connection.ReadMessage(); err == nil {
t.Fatal("active connection remained open after Stop")
}
}
func waitForHandlerCalls(t *testing.T, handler *recordingHandler) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
handler.mutex.Lock()
done := len(handler.spoken) == 1 && len(handler.braille) == 1 && handler.canceled == 1
handler.mutex.Unlock()
if done {
return
}
time.Sleep(10 * time.Millisecond)
}
}
func TestBindAddressUsesPythonDefault(t *testing.T) {
t.Setenv(hostEnv, "http://127.0.0.1")
if address := bindAddress(); address != "127.0.0.1:3457" {
t.Fatalf("default address = %q", address)
}
}
func TestStopWithoutStartSucceeds(t *testing.T) {
server := New(nil, nil)
if err := server.Stop(context.Background()); err != nil {
t.Fatalf("stop unstarted server: %v", err)
}
}