Files
cthulhu/internal/app/browser_editor_native_test.go

149 lines
4.1 KiB
Go

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