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