136 lines
3.6 KiB
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")
|
|
}
|
|
}
|