Files
cthulhu/internal/app/browser_lists_native_test.go

136 lines
3.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"
"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")
}
}