Files
cthulhu/internal/app/messaging_test.go
2026-10-27 00:40:49 -04:00

247 lines
8.5 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package app
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
"git.stormux.org/storm/cthulhu/internal/config"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/messaging"
)
func pidginMessageEvent(text string) atspi.Event {
event := atspi.NewEvent(atspi.TextInsertedEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Role: "text", ApplicationProcess: "pidgin"})
event.Window = atspi.NewObjectID(":1.42", "/window")
event.ChatMessage = &messaging.Message{Provider: ":1.42", Conversation: "/transcript", Window: "/window", RoomName: "Conversation", Text: text}
event.ForegroundMismatch = true
return event
}
func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
adapter := &fakeAdapter{events: make(chan atspi.Event)}
queue := &fakeSpeechQueue{spoken: make(chan string, 16)}
requests := make(chan input.Command)
done := make(chan error, 1)
go func() {
done <- processEventsWithReview(ctx, slog.New(slog.DiscardHandler), queue, adapter, requests, 0)
}()
defer func() { cancel(); <-done }()
focus := ownerEvent(atspi.FocusedStateEvent, ":1.42", "/window", "/entry")
focus.Source.ApplicationProcess, focus.Source.Role, focus.Source.Name = "pidgin", "entry", "Message input"
focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1
adapter.events <- focus
expectActivationSpeech(t, queue.spoken, "Message input, entry")
adapter.events <- pidginMessageEvent("First message")
expectActivationSpeech(t, queue.spoken, "First message")
adapter.events <- pidginMessageEvent("Second message")
expectActivationSpeech(t, queue.spoken, "Second message")
requests <- input.MessageCommand(0, false)
expectActivationSpeech(t, queue.spoken, "Second message")
requests <- input.MessageCommand(1, false)
expectActivationSpeech(t, queue.spoken, "First message")
requests <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message input, entry")
// Background chat never becomes current focus, and another provider cannot
// borrow a trusted message's copied metadata.
forged := pidginMessageEvent("Do not announce")
forged.Source.ApplicationProcess = "chromium"
adapter.events <- forged
requests <- input.WhereAmI
expectActivationSpeech(t, queue.spoken, "Message input, entry")
select {
case text := <-queue.spoken:
t.Fatalf("unexpected output: %q", text)
default:
}
}
func TestMessagingCopyResultsAndSecurityCancellation(t *testing.T) {
for _, fail := range []bool{false, true} {
t.Run(map[bool]string{false: "success", true: "failure"}[fail], func(t *testing.T) {
queue := &learnSpeechRecorder{}
copied := make(chan string, 1)
controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error {
copied <- text
if fail {
return errors.New("clipboard failed")
}
return nil
})
defer controller.stop()
event := pidginMessageEvent("Unicode 界\nsecond line")
controller.handle(event)
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case result := <-controller.results:
controller.copied(result)
case <-time.After(time.Second):
t.Fatal("copy did not finish")
}
if text := <-copied; text != event.ChatMessage.Text {
t.Fatalf("clipboard text = %q", text)
}
want := "Copied Unicode 界\nsecond line to clipboard."
if fail {
want = "Error accessing clipboard"
}
if got := queue.texts[len(queue.texts)-1]; got != want {
t.Fatalf("copy response = %q", got)
}
})
}
gate := &focusGate{}
queue := &learnSpeechRecorder{}
started := make(chan struct{})
finished := make(chan struct{})
controller := newMessagingController(context.Background(), queue, gate, nil, func(ctx context.Context, text string) error {
close(started)
<-ctx.Done()
close(finished)
return ctx.Err()
})
defer controller.stop()
controller.handle(pidginMessageEvent("Pending copy"))
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("copy worker blocked event loop")
}
controller.reset()
select {
case <-finished:
case <-time.After(time.Second):
t.Fatal("copy did not cancel")
}
if len(queue.texts) != 1 {
t.Fatal("cancelled copy announced completion")
}
controller.review(input.MessageCommand(0, false), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
if len(queue.texts) != 1 {
t.Fatal("cleared history was replayed")
}
}
func TestMessagingSuppressesTypingAndBypassesOnlySuspension(t *testing.T) {
queue := &learnSpeechRecorder{}
gate := &focusGate{}
gate.SetSuspended(true)
braille := &flashRecorder{}
controller := newMessagingController(context.Background(), gatedSpeech{speech: queue, gate: gate}, gate, nil, writeClipboardText)
controller.braille = braille
defer controller.stop()
event := pidginMessageEvent("Incoming message")
if !controller.handle(event) || len(queue.texts) != 1 || len(braille.messages) != 1 {
t.Fatal("background message did not reach speech and Braille")
}
event.ChatMessage.Typing = true
controller.handle(event)
if len(queue.texts) != 1 {
t.Fatal("typing status was announced")
}
if _, ok := controller.history.Previous(":1.42", 1); ok {
t.Fatal("typing status entered history")
}
gate.secure.Store(true)
event.ChatMessage.Typing = false
controller.handle(event)
if len(queue.texts) != 1 || len(braille.messages) != 1 {
t.Fatal("locked message escaped output gate")
}
}
func TestNativeMessagingCopyPreservesClipboardText(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_MESSAGING") != "1" {
t.Skip("requires isolated Xorg clipboard")
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
speech := &learnSpeechRecorder{}
controller := newMessagingController(ctx, speech, nil, nil, writeClipboardText)
defer controller.stop()
text := "Username: one\ntwo 界"
controller.handle(pidginMessageEvent(text))
controller.review(input.MessageCommand(0, true), atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"})
select {
case result := <-controller.results:
if result.err != nil {
t.Fatal(result.err)
}
controller.copied(result)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
got, err := readClipboardTarget(ctx, "UTF8_STRING")
if err != nil || got != text {
t.Fatalf("clipboard = %q, %v", got, err)
}
if speech.texts[len(speech.texts)-1] != "Copied "+text+" to clipboard." {
t.Fatal("successful copy was not confirmed")
}
}
func TestMessagingOutputQueueFlashesOnce(t *testing.T) {
live := &liveSettings{}
live.Store(config.DefaultSettings())
speech, braille := &learnSpeechRecorder{}, &flashRecorder{}
controller := newMessagingController(context.Background(), outputQueue{speech: speech, braille: braille, live: live}, nil, live, writeClipboardText)
controller.braille = braille
defer controller.stop()
controller.handle(pidginMessageEvent("One message"))
if len(braille.messages) != 1 || len(speech.texts) != 1 {
t.Fatal("message was duplicated by shared output")
}
}
func TestMessagingQueuedCopyKeepsNewestMessage(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
started := make(chan string, 3)
release := make(chan struct{})
controller := newMessagingController(ctx, &learnSpeechRecorder{}, nil, nil, func(writeCtx context.Context, text string) error {
started <- text
if text == "First" {
select {
case <-release:
case <-ctx.Done():
}
}
return writeCtx.Err()
})
defer controller.stop()
current := atspi.Snapshot{ApplicationProcess: "pidgin", BusName: ":1.42"}
controller.handle(pidginMessageEvent("First"))
controller.review(input.MessageCommand(0, true), current)
select {
case <-started:
case <-ctx.Done():
t.Fatal(ctx.Err())
}
for _, text := range []string{"Second", "Third"} {
controller.handle(pidginMessageEvent(text))
controller.review(input.MessageCommand(0, true), current)
}
close(release)
select {
case result := <-controller.results:
if result.text != "Third" {
t.Fatalf("completed obsolete copy %q", result.text)
}
case <-ctx.Done():
t.Fatal("newest queued copy was lost")
}
if text := <-started; text != "Third" {
t.Fatalf("copied obsolete queued message %q", text)
}
}