Files
cthulhu/internal/app/browser_menu_native_test.go

201 lines
5.6 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"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/atspi"
)
type menuRecordingAdapter struct {
*atspi.Adapter
events chan atspi.Event
}
func (adapter *menuRecordingAdapter) Events() <-chan atspi.Event { return adapter.events }
func TestNativeBrowserMenuControls(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" {
t.Skip("requires a private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
native := atspi.New()
if err := native.Start(ctx); err != nil {
t.Fatal(err)
}
defer native.Stop(context.Background())
adapter := &menuRecordingAdapter{Adapter: native, events: make(chan atspi.Event, 256)}
observed := make(chan atspi.Event, 256)
pumpDone := make(chan struct{})
go func() {
defer close(pumpDone)
for {
select {
case event := <-native.Events():
select {
case observed <- event:
case <-ctx.Done():
return
}
select {
case adapter.events <- event:
case <-ctx.Done():
return
}
case <-ctx.Done():
return
}
}
}()
queue := &autocompleteRequestQueue{requests: make(chan speechRequest, 256)}
done := make(chan struct{})
go func() {
defer close(done)
processEventsWithInput(ctx, slog.New(slog.DiscardHandler), queue, adapter, nil, nil, 0, nil, nil, nil, nil)
}()
defer func() { cancel(); <-done; <-pumpDone }()
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)
}
}
waitEvent := func(match func(atspi.Event) bool) atspi.Event {
t.Helper()
for {
select {
case event := <-observed:
if match(event) {
t.Logf("event %s (native %s): %s %q, origin %s", event.Type, event.NativeType, event.Source.Role, event.Source.Name, event.OriginPath)
return event
}
case <-ctx.Done():
t.Fatal("missing menu event: ", ctx.Err())
return atspi.Event{}
}
}
}
waitSpeech := func(name string) {
t.Helper()
if name == "" {
t.Fatal("menu control has no accessible name")
}
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
select {
case request := <-queue.requests:
if strings.Contains(request.Text, name) {
t.Logf("speech: %q", request.Text)
return
}
case <-timer.C:
t.Fatalf("missing menu speech %q", name)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
settle := func() {
t.Helper()
timer := time.NewTimer(100 * time.Millisecond)
defer timer.Stop()
for {
select {
case <-observed:
timer.Reset(100 * time.Millisecond)
case request := <-queue.requests:
if request.Cause == atspi.SelectionChangedEvent || request.Cause == atspi.ActiveDescendantEvent {
t.Fatalf("duplicate menu speech without another key: %q", request.Text)
}
case <-timer.C:
return
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
keys("F10")
waitEvent(func(e atspi.Event) bool { return isFocusGainEvent(e) && strings.Contains(e.Source.Name, "Brave") })
keys("Down")
waitEvent(func(e atspi.Event) bool { return e.Type == atspi.WindowActivatedEvent && e.Source.Role == "menu" })
// The app menu wraps; find the row by its real selection rather than
// relying on a fixed number of entries or the current browser profile.
for steps := 0; ; steps++ {
if steps >= 40 {
t.Fatal("Sidebar row absent from app menu")
}
keys("Down")
event := waitEvent(func(e atspi.Event) bool { return e.Type == atspi.SelectionChangedEvent && e.Source.Role == "menu item" })
waitSpeech(event.Source.Name)
if event.Source.Name == "Sidebar" {
break
}
settle()
}
inline := func(role, rowName string) atspi.Event {
return waitEvent(func(e atspi.Event) bool {
return (e.Type == atspi.SelectedStateEvent || e.NativeType == atspi.SelectedStateEvent) &&
e.Detail1 == 1 && e.Source.Role == role && e.Source.Name != rowName
})
}
first := inline("button", "Sidebar")
waitSpeech(first.Source.Name)
settle()
for _, chord := range []string{"Down", "Down", "Up", "Up", "Down", "Up"} {
keys(chord)
event := inline("button", "Sidebar")
if event.Type != atspi.ActiveDescendantEvent || event.OriginPath == event.Source.Path {
t.Fatalf("inline control lacks menu ownership: %+v", event)
}
waitSpeech(event.Source.Name)
settle()
}
for range 2 {
keys("Down")
event := inline("button", "Sidebar")
waitSpeech(event.Source.Name)
settle()
}
// Continue through the normal entries to the second compound row.
// Its inner controls use menu-item roles rather than button roles.
for steps := 0; ; steps++ {
if steps >= 25 {
t.Fatal("Zoom row absent from app menu")
}
keys("Down")
event := waitEvent(func(e atspi.Event) bool { return e.Type == atspi.SelectionChangedEvent && e.Source.Role == "menu item" })
waitSpeech(event.Source.Name)
if event.Source.Name == "Zoom" {
break
}
settle()
}
zoom := inline("menu item", "Zoom")
waitSpeech(zoom.Source.Name)
settle()
for _, chord := range []string{"Down", "Down", "Up", "Up", "Down", "Up"} {
keys(chord)
event := inline("menu item", "Zoom")
if event.Type != atspi.ActiveDescendantEvent || event.OriginPath != zoom.OriginPath {
t.Fatalf("Zoom control lacks the same menu owner: %+v", event)
}
waitSpeech(event.Source.Name)
settle()
}
keys("Escape")
waitEvent(func(e atspi.Event) bool { return e.Type == atspi.WindowActivatedEvent && e.Source.Role != "menu" })
keys("ctrl+l")
waitSpeech("Address and search bar")
}