// Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "os" "os/exec" "strings" "sync/atomic" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" ) func TestNativeBrowserEmoji(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser fixture") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() adapter := atspi.New() keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) for _, service := range []interface { Start(context.Context) error Stop(context.Context) error }{adapter, keys} { if err := service.Start(ctx); err != nil { t.Fatal(err) } defer service.Stop(context.Background()) } output := &ocrTestOutput{speech: make(chan speechRequest, 256)} ui := &internalUI{keys: keys, emojiSource: adapter, emojiCopier: xclipOCRCopier{}, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), done: make(chan struct{})} var focus atomic.Pointer[atspi.Snapshot] go ui.run(ctx) relayDone := make(chan struct{}) go func() { defer close(relayDone) for { select { case <-ctx.Done(): return case event := <-keys.Commands(): ui.Handle(event) case event := <-adapter.Events(): if isFocusGainEvent(event) { focus.Store(&event.Source) } } } }() defer func() { cancel(); <-ui.done; <-relayDone }() key := func(names ...string) { t.Helper() if data, err := exec.CommandContext(ctx, "xdotool", append([]string{"key"}, names...)...).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, data) } } waitSpeech := func(prefix string) { t.Helper() for { select { case message := <-output.speech: t.Logf("UI: %s", message.Text) if strings.HasPrefix(message.Text, prefix) { return } case <-ctx.Done(): t.Fatalf("missing %q", prefix) } } } for _, name := range []string{"Emoji input", "Emoji textarea", "Emoji editor"} { for attempt := 0; attempt < 12; attempt++ { key("Tab") deadline := time.Now().Add(200 * time.Millisecond) for time.Now().Before(deadline) { if current := focus.Load(); current != nil && current.Name == name { break } time.Sleep(5 * time.Millisecond) } if current := focus.Load(); current != nil && current.Name == name { break } } current := focus.Load() if current == nil || current.Name != name { t.Fatalf("field %s not focused: %+v", name, current) } key("ctrl+a", "Insert+ctrl+e") waitSpeech("Emoji Menu. All tab") key("Tab", "a", "s", "t", "r", "o", "n", "a", "u", "t") key("Return") waitSpeech("Inserted Astronaut") // Read back from the start, independently of the insertion's caret placement. key("ctrl+Home") var text string for { content, err := adapter.ReadFocusedText(ctx, current.ID()) text = content.Text want := "🧑‍🚀" if err == nil && text == want { break } if ctx.Err() != nil { t.Fatalf("%s replacement: %q %v", name, text, err) } time.Sleep(10 * time.Millisecond) } } }