// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "errors" "fmt" "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 TestMumbleMessagingReviewBoundsOwnershipAndRetirement(t *testing.T) { queue, braille := &learnSpeechRecorder{}, &flashRecorder{} controller := newMessagingController(context.Background(), queue, nil, nil, func(_ context.Context, text string) error { if text != "Mumble message 3" { return fmt.Errorf("copied wrong message: %q", text) } return nil }) controller.braille = braille defer controller.stop() current := atspi.Snapshot{ApplicationProcess: "mumble", BusName: ":1.42"} for i := 0; i < 15; i++ { event := pidginMessageEvent(fmt.Sprintf("Mumble message %d", i)) event.Source.ApplicationProcess = "mumble" if i%2 == 0 { event.Source.Role = "label" } if !controller.handle(event) { t.Fatal("Mumble log insertion was not handled") } } for i := 0; i < 12; i++ { controller.review(input.MessageCommand(i, false), current) want := fmt.Sprintf("Mumble message %d", 14-i) if queue.texts[len(queue.texts)-1] != want || braille.messages[len(braille.messages)-1] != want { t.Fatalf("review slot %d did not present %q", i, want) } } controller.review(input.MessageCommand(11, true), current) select { case result := <-controller.results: if result.err != nil { t.Fatal(result.err) } controller.copied(result) case <-time.After(time.Second): t.Fatal("Mumble F12 copy did not complete") } if queue.texts[len(queue.texts)-1] != "Copied Mumble message 3 to clipboard." { t.Fatal("Mumble F12 copy was not confirmed") } count := len(queue.texts) for _, focus := range []atspi.Snapshot{ {ApplicationProcess: "mumble", BusName: ":1.43"}, {ApplicationProcess: "chromium", BusName: ":1.42"}, {ApplicationProcess: "pidgin", BusName: ":1.42"}, } { controller.review(input.MessageCommand(11, false), focus) } retired := atspi.NewEvent(atspi.DefunctStateEvent, atspi.Snapshot{BusName: ":1.42", Path: "/transcript", Defunct: true}) retired.Detail1 = 1 controller.handle(retired) controller.review(input.MessageCommand(0, false), current) if len(queue.texts) != count || len(braille.messages) != count { t.Fatal("unowned or retired history was presented") } } func TestMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T) { for _, process := range []string{"pidgin", "mumble"} { t.Run(process, func(t *testing.T) { testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t, process) }) } } func testMessagingBackgroundPresentationKeepsFocusAndUsesCommands(t *testing.T, process string) { 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 = process, "entry", "Message input" focus.Source.Editable, focus.Source.Focused, focus.Detail1 = true, true, 1 adapter.events <- focus expectActivationSpeech(t, queue.spoken, "Message input, entry") messageEvent := func(text string) atspi.Event { event := pidginMessageEvent(text) event.Source.ApplicationProcess = process return event } adapter.events <- messageEvent("First message") expectActivationSpeech(t, queue.spoken, "First message") adapter.events <- messageEvent("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) } }