201 lines
5.6 KiB
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")
|
|
}
|