// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "log/slog" "os" "os/exec" "strings" "sync/atomic" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/presentation" ) func TestNativeBrowserEditorTyping(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) listener := xorg.New() if err := listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) var focus atomic.Pointer[atspi.Snapshot] var insertion atomic.Pointer[atspi.Event] observed := &browserEventProbe{Adapter: adapter, events: make(chan atspi.Event, 64)} relayDone := make(chan struct{}) go func() { defer close(relayDone) for { select { case event := <-adapter.Events(): if isFocusGainEvent(event) { focus.Store(&event.Source) } if event.Type == atspi.TextInsertedEvent && event.Source.Role == "paragraph" { insertion.Store(&event) } select { case observed.events <- event: case <-ctx.Done(): return } case <-ctx.Done(): return } } }() defer func() { cancel(); <-relayDone }() queue := &fakeSpeechQueue{spoken: make(chan string, 128)} done := make(chan struct{}) go func() { defer close(done) options := presentation.DefaultOptions() options.EnableKeyEcho = false processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, observed, nil, nil, 20*time.Millisecond, nil, listener, nil, nil, options) }() defer func() { cancel(); <-done }() keys := func(chord string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", chord).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } collect := func() []string { t.Helper() timer := time.NewTimer(180 * time.Millisecond) defer timer.Stop() var texts []string for { select { case text := <-queue.spoken: texts = append(texts, text) case <-timer.C: return texts case <-ctx.Done(): t.Fatal(ctx.Err()) } } } for attempt := 0; attempt < 20; attempt++ { keys("Tab") collect() if current := focus.Load(); current != nil && current.Name == "Message composer" { break } } editor := focus.Load() if editor == nil || editor.Name != "Message composer" || !editor.Editable || editor.Role != "entry" { t.Fatalf("composer focus = %+v", editor) } keys("End") collect() for _, letter := range []string{"h", "e", "l", "l", "o"} { keys(letter) if got := collect(); len(got) != 0 { t.Fatalf("typing %q with echo disabled spoke %q", letter, got) } } if event := insertion.Load(); event == nil || event.Source.EditableRoot != editor.ID() { t.Fatalf("paragraph insertion lost editor ownership: %+v", event) } keys("Left") if got := collect(); len(got) != 1 || got[0] != "o" { t.Fatalf("character reading = %q", got) } keys("Down") if got := collect(); len(got) != 1 || got[0] != "Following line" { t.Fatalf("next line reading = %q", got) } keys("Up") if got := collect(); len(got) != 1 || got[0] != "Draft:hello" { t.Fatalf("line reading = %q", got) } keys("End") collect() keys("BackSpace") if got := collect(); len(got) != 1 || got[0] != "o" { t.Fatalf("deletion = %q", got) } keys("Tab") if got := collect(); len(got) == 0 || !strings.Contains(strings.Join(got, " "), "Other editor") { t.Fatalf("next editor focus = %q", got) } keys("x") if got := collect(); len(got) != 0 { t.Fatalf("typing in second editor spoke %q", got) } if current, event := focus.Load(), insertion.Load(); current == nil || current.Name != "Other editor" || event == nil || event.Source.EditableRoot != current.ID() || event.Source.EditableRoot == editor.ID() { t.Fatalf("second editor ownership: focus=%+v insertion=%+v", current, event) } }