// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/input" "git.stormux.org/storm/cthulhu/internal/platform/xorg" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeBrowserLists(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser list fixture") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() adapter := atspi.New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) key := 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) } } var focus atspi.ObjectID for attempt := 0; attempt < 25 && !focus.Valid(); attempt++ { key("Tab") timer := time.NewTimer(150 * time.Millisecond) loop: for { select { case event := <-adapter.Events(): if isFocusEvent(event.Type) && event.Source.Name == "Browser foundation" { focus = event.Source.ID() break loop } case <-timer.C: break loop case <-ctx.Done(): t.Fatal(ctx.Err()) } } timer.Stop() } if !focus.Valid() { t.Fatal("fixture heading focus missing") } worker, _ := review.NewWorker(adapter, 2*time.Second) if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.SyncFocus(focus) keys := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) if err := keys.Start(ctx); err != nil { t.Fatal(err) } defer keys.Stop(context.Background()) keys.SetBrowseNavigation(true) for !keys.BrowseNavigationEnabled() { if ctx.Err() != nil { t.Fatal(ctx.Err()) } time.Sleep(5 * time.Millisecond) } output := &ocrTestOutput{speech: make(chan speechRequest, 32)} ui := &internalUI{keys: keys, documents: worker, speech: output, gate: newFocusGate(keys), events: make(chan input.CommandEvent, 32), done: make(chan struct{})} go ui.run(ctx) done := make(chan struct{}) go func() { defer close(done) for { select { case <-ctx.Done(): return case event := <-keys.Commands(): ui.Handle(event) case <-adapter.Events(): // Keep the native event queue draining. } } }() defer func() { cancel(); <-ui.done; <-done }() wait := func(want string) { t.Helper() select { case message := <-output.speech: if !strings.HasPrefix(message.Text, want) { t.Fatalf("speech %q, want prefix %q", message.Text, want) } case <-ctx.Done(): t.Fatalf("missing %q: %v", want, ctx.Err()) } } key("alt+shift+k") wait("Links: 302 items found. First link") key("Down") wait("Second link") key("Return") select { case result := <-worker.Results(): if result.Err != nil || !result.Moved || result.DocumentPosition.Snapshot.Name != "Second link" { t.Fatalf("list selection: %+v", result) } case <-ctx.Done(): t.Fatal("selection did not reach review worker") } key("alt+shift+k") wait("Links: 302 items found. Second link") key("Escape") // The next shortcut follows Escape in the native input stream. key("alt+shift+a") wait("Clickables: 1 item found. Custom clickable") key("Escape") key("alt+shift+2") wait("Headings at level 2: 1 item found. Second heading") key("Escape") key("alt+shift+g") wait("Images: 0 items found") if keys.InternalUISession() != 0 { t.Fatal("empty list retained keyboard capture") } }