Files

325 lines
10 KiB
Go

// Cthulhu screen reader.
// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
//
// This program is free software: you can redistribute it and/or modify it
// under the terms of the GNU General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option)
// any later version.
// This program is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
// FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
// more details.
// You should have received a copy of the GNU General Public License along with
// this program. If not, see <https://www.gnu.org/licenses/gpl-3.0.html>.
package remote
import (
"context"
"errors"
"log/slog"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/godbus/dbus/v5"
)
type recordingHandler struct {
mutex sync.Mutex
presented []string
spoken []string
interrupts []bool
canceled int
braille []string
wine []AccessibleEvent
result bool
}
func (handler *recordingHandler) PresentMessage(message string) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.presented = append(handler.presented, message)
return handler.result
}
func (handler *recordingHandler) SpeakText(text string, interrupt bool) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.spoken = append(handler.spoken, text)
handler.interrupts = append(handler.interrupts, interrupt)
return handler.result
}
func (handler *recordingHandler) BrailleMessage(text string) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.braille = append(handler.braille, text)
return false
}
func (handler *recordingHandler) CancelSpeech() bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.canceled++
return handler.result
}
func (handler *recordingHandler) PresentAccessibleEvent(event AccessibleEvent) bool {
handler.mutex.Lock()
defer handler.mutex.Unlock()
handler.wine = append(handler.wine, event)
return handler.result
}
func authorizedEndpoint(handler Handler) *endpoint {
endpoint := newEndpoint(slog.New(slog.DiscardHandler), handler, func(dbus.Sender) bool { return true })
endpoint.startAccepting()
return endpoint
}
type fakeConnection struct {
mutex sync.Mutex
exports []string
requestReply dbus.RequestNameReply
requestError error
releaseReply dbus.ReleaseNameReply
closed bool
failExport string
}
func (connection *fakeConnection) Export(value any, _ dbus.ObjectPath, iface string) error {
connection.mutex.Lock()
defer connection.mutex.Unlock()
if value != nil && iface == connection.failExport {
return errors.New("simulated export failure")
}
action := "export "
if value == nil {
action = "unexport "
}
connection.exports = append(connection.exports, action+iface)
return nil
}
func TestServiceExportFailureClosesOnlyItsConnection(t *testing.T) {
failed := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, failExport: "org.freedesktop.DBus.Introspectable"}
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return failed, nil })
service.orca = true
if err := service.Start(context.Background()); err == nil {
t.Fatal("export failure was ignored")
}
if !failed.closed {
t.Fatal("failed Orca connection was left open")
}
ready := &fakeConnection{requestReply: dbus.RequestNameReplyPrimaryOwner, releaseReply: dbus.ReleaseNameReplyReleased}
native := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) { return ready, nil })
if err := native.Start(context.Background()); err != nil {
t.Fatalf("native service after Orca export failure: %v", err)
}
if err := native.Stop(context.Background()); err != nil {
t.Fatal(err)
}
}
func (connection *fakeConnection) RequestName(string, dbus.RequestNameFlags) (dbus.RequestNameReply, error) {
return connection.requestReply, connection.requestError
}
func (connection *fakeConnection) ReleaseName(string) (dbus.ReleaseNameReply, error) {
return connection.releaseReply, nil
}
func (connection *fakeConnection) UnixUser(context.Context, string) (uint32, error) {
return uint32(os.Getuid()), nil
}
func (connection *fakeConnection) Close() error {
connection.closed = true
return nil
}
func TestServiceOwnsAndReleasesName(t *testing.T) {
fakeBus := &fakeConnection{
requestReply: dbus.RequestNameReplyPrimaryOwner,
releaseReply: dbus.ReleaseNameReplyReleased,
}
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) {
return fakeBus, nil
})
if err := service.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
if err := service.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
if !fakeBus.closed {
t.Fatal("session bus connection was not closed")
}
want := []string{
"export " + InterfaceName,
"export org.freedesktop.DBus.Introspectable",
"export " + SpeechManagerInterface,
"export org.freedesktop.DBus.Introspectable",
"unexport org.freedesktop.DBus.Introspectable",
"unexport " + SpeechManagerInterface,
"unexport org.freedesktop.DBus.Introspectable",
"unexport " + InterfaceName,
}
if got := strings.Join(fakeBus.exports, ","); got != strings.Join(want, ",") {
t.Fatalf("exports = %q, want %q", got, strings.Join(want, ","))
}
}
func TestNativeSessionBusSelfVoice(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_DBUS_TEST") != "1" {
t.Skip("set CTHULHU_NATIVE_DBUS_TEST=1 inside a private session bus")
}
handler := &recordingHandler{result: true}
service := New(slog.New(slog.DiscardHandler), handler)
if err := service.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
t.Cleanup(func() { service.Stop(context.Background()) })
conflict := New(slog.New(slog.DiscardHandler), handler)
if err := conflict.Start(context.Background()); err == nil {
conflict.Stop(context.Background())
t.Fatal("second service acquired the well-known name")
}
client, err := dbus.ConnectSessionBus()
if err != nil {
t.Fatalf("connect client: %v", err)
}
defer client.Close()
object := client.Object(BusName, ObjectPath)
var accepted bool
if err := object.Call(InterfaceName+".SpeakText", 0, "Native message", false).Store(&accepted); err != nil {
t.Fatalf("SpeakText: %v", err)
}
if !accepted {
t.Fatal("SpeakText was not accepted")
}
if err := object.Call(InterfaceName+".PresentAccessibleEvent", 0,
"42:0:0", "focus", "Start", "button", "", "", "", int32(1), int32(3), "Game").Store(&accepted); err != nil {
t.Fatalf("PresentAccessibleEvent: %v", err)
}
if !accepted {
t.Fatal("PresentAccessibleEvent was not accepted")
}
if err := object.Call(InterfaceName+".BrailleMessage", 0, "Braille").Store(&accepted); err != nil {
t.Fatalf("BrailleMessage: %v", err)
}
if accepted {
t.Fatal("BrailleMessage succeeded without a braille backend")
}
var ssmlResult int32
if err := object.Call(InterfaceName+".SpeakSsml", 0, "request", "<speak>Text</speak>").Store(&ssmlResult); err != nil {
t.Fatalf("SpeakSsml: %v", err)
}
if ssmlResult != windowsErrorNotSupported {
t.Fatalf("SpeakSsml = %d, want %d", ssmlResult, windowsErrorNotSupported)
}
var xml string
if err := object.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil {
t.Fatalf("Introspect: %v", err)
}
for _, method := range []string{"PresentMessage", "SpeakText", "BrailleMessage", "CancelSpeech", "SpeakSsml", "PresentAccessibleEvent"} {
if !strings.Contains(xml, `method name="`+method+`"`) {
t.Fatalf("introspection does not contain %s: %s", method, xml)
}
}
manager := client.Object(BusName, SpeechManagerPath)
for _, notifyUser := range []bool{true, false} {
if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, notifyUser).Store(&accepted); err != nil || !accepted {
t.Fatalf("InterruptSpeech(%v): accepted=%v, error=%v", notifyUser, accepted, err)
}
}
handler.mutex.Lock()
canceled := handler.canceled
handler.mutex.Unlock()
if canceled != 2 {
t.Fatalf("InterruptSpeech cancellations = %d", canceled)
}
if err := manager.Call("org.freedesktop.DBus.Introspectable.Introspect", 0).Store(&xml); err != nil {
t.Fatal(err)
}
if !strings.Contains(xml, `method name="InterruptSpeech"`) || !strings.Contains(xml, `type="b" direction="in"`) {
t.Fatalf("missing SpeechManager signature: %s", xml)
}
if err := service.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
if err := manager.Call(SpeechManagerInterface+".InterruptSpeech", 0, true).Store(&accepted); err == nil {
t.Fatal("SpeechManager accepted a call after Stop")
}
replacement := New(slog.New(slog.DiscardHandler), handler)
if err := replacement.Start(context.Background()); err != nil {
t.Fatalf("replacement Start after name release: %v", err)
}
if err := replacement.Stop(context.Background()); err != nil {
t.Fatalf("replacement Stop: %v", err)
}
}
func TestServiceRejectsNameConflictAndCleansUp(t *testing.T) {
fakeBus := &fakeConnection{requestReply: dbus.RequestNameReplyExists}
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{result: true}, func() (connection, error) {
return fakeBus, nil
})
err := service.Start(context.Background())
if err == nil || !strings.Contains(err.Error(), "already owned") {
t.Fatalf("Start error = %v, want name ownership error", err)
}
if !fakeBus.closed {
t.Fatal("conflicted session bus connection was not closed")
}
}
func TestServicePropagatesConnectionFailure(t *testing.T) {
want := errors.New("no session bus")
service := newService(slog.New(slog.DiscardHandler), &recordingHandler{}, func() (connection, error) {
return nil, want
})
if err := service.Start(context.Background()); !errors.Is(err, want) {
t.Fatalf("Start error = %v, want %v", err, want)
}
}
func TestStopWaitsForActiveCall(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
endpoint := newEndpoint(slog.New(slog.DiscardHandler), &recordingHandler{}, func(dbus.Sender) bool {
close(started)
<-release
return true
})
endpoint.startAccepting()
done := make(chan struct{})
go func() {
endpoint.CancelSpeech(dbus.Sender(":1.9"))
close(done)
}()
<-started
idle := endpoint.stopAccepting()
select {
case <-idle:
t.Fatal("endpoint became idle while a call was active")
default:
}
close(release)
select {
case <-idle:
case <-time.After(time.Second):
t.Fatal("endpoint did not become idle")
}
<-done
}