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