Files
cthulhu/internal/app/browser_find_native_test.go
2026-11-05 12:34:36 -05:00

225 lines
6.0 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"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
"git.stormux.org/storm/cthulhu/internal/review"
)
func TestNativeBrowserFind(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" && os.Getenv("CTHULHU_NATIVE_FIREFOX") != "1" {
t.Skip("requires private browser session")
}
ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
defer cancel()
adapter := atspi.New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
listener := xorg.New()
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
commands := xorg.NewCommands([]string{"Insert", "KP_Insert"}, input.DesktopKeyboardLayout)
if err := commands.Start(ctx); err != nil {
t.Fatal(err)
}
defer commands.Stop(context.Background())
worker, err := review.NewWorker(adapter, 2*time.Second)
if err != nil {
t.Fatal(err)
}
if err := worker.Start(ctx); err != nil {
t.Fatal(err)
}
defer worker.Stop(context.Background())
browser := newBrowserModeController(listener, commands, adapter)
if err := browser.Start(ctx); err != nil {
t.Fatal(err)
}
defer browser.Stop(context.Background())
output := &ocrTestOutput{speech: make(chan speechRequest, 128)}
logFile, err := os.CreateTemp("/tmp", "cthulhu-find-*.log")
if err != nil {
t.Fatal(err)
}
defer logFile.Close()
t.Logf("diagnostics: %s", logFile.Name())
probe := &menuRecordingAdapter{Adapter: adapter, events: make(chan atspi.Event, 256)}
pumpDone := make(chan struct{})
go func() {
defer close(pumpDone)
for {
select {
case <-ctx.Done():
return
case event := <-adapter.Events():
if isFocusGainEvent(event) {
t.Logf("native focus %s %q editable=%t", event.Source.Role, event.Source.Name, event.Source.Editable)
}
select {
case probe.events <- event:
case <-ctx.Done():
return
}
}
}
}()
loopDone, commandDone := make(chan struct{}), make(chan struct{})
go func() {
defer close(loopDone)
processEventsWithInput(ctx, slog.New(slog.NewTextHandler(logFile, &slog.HandlerOptions{Level: slog.LevelDebug})), output, probe, nil, worker,
20*time.Millisecond, nil, browser, nil, nil)
}()
go func() {
defer close(commandDone)
for {
select {
case <-ctx.Done():
return
case event := <-commands.Commands():
browser.observeCommand(event.Command)
if command := documentCaretCommand(event.Command); command != 0 {
worker.MoveDocumentCaret(command)
} else if target, level, backwards := documentReviewCommand(event.Command); target != 0 {
worker.MoveDocument(target, level, backwards)
}
}
}
}()
defer func() { cancel(); <-loopDone; <-commandDone; <-pumpDone }()
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)
}
}
waitSpeech := func(text, role string) speechRequest {
t.Helper()
timer := time.NewTimer(3 * time.Second)
defer timer.Stop()
for {
select {
case request := <-output.speech:
t.Logf("speech: %s %q (%s)", request.Role, request.Text, request.Cause)
if strings.Contains(request.Text, text) && (role == "" || request.Role == role) {
return request
}
case err := <-commands.Failures():
t.Fatal(err)
case <-timer.C:
t.Fatalf("missing speech %q", text)
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
}
waitStart := func() {
t.Helper()
for {
if request := waitSpeech("Find fixture start", ""); request.Cause == "document-caret" {
return
}
}
}
// A focusable heading gives both browser families a known browse-mode start.
key("ctrl+l")
key("Escape")
heading := false
for attempt := 0; attempt < 25 && !heading; attempt++ {
key("Tab")
timer := time.NewTimer(200 * time.Millisecond)
drain:
for {
select {
case request := <-output.speech:
if request.Role == "heading" && strings.Contains(request.Text, "Find fixture start") {
heading = true
break drain
}
case <-timer.C:
break drain
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
timer.Stop()
}
if !heading {
t.Fatal("fixture heading focus missing")
}
for !commands.BrowseNavigationEnabled() {
select {
case <-time.After(5 * time.Millisecond):
case <-ctx.Done():
t.Fatal("browse grabs missing")
}
}
key("ctrl+Home")
waitStart()
// Wait for the native caret/focus echoes of navigation before opening
// browser chrome. They must not satisfy the Find field assertion.
quiet := time.NewTimer(200 * time.Millisecond)
defer quiet.Stop()
settle:
for {
select {
case <-output.speech:
quiet.Reset(200 * time.Millisecond)
case <-quiet.C:
break settle
case <-ctx.Done():
t.Fatal(ctx.Err())
}
}
key("ctrl+f")
waitSpeech("Find", "entry")
for commands.BrowseNavigationEnabled() {
select {
case <-time.After(5 * time.Millisecond):
case <-ctx.Done():
t.Fatal("Find retained browse grabs")
}
}
key("l")
waitSpeech("l", "entry")
key("Tab")
waitSpeech("", "button")
key("shift+Tab")
waitSpeech("Find", "entry")
key("Escape")
for !commands.BrowseNavigationEnabled() {
select {
case <-time.After(5 * time.Millisecond):
case <-ctx.Done():
t.Fatal("Escape did not restore browse grabs")
}
}
// Firefox can return native focus to the document root after Find,
// whereas Chromium restores the previously focused heading.
key("ctrl+Home")
waitStart()
key("h")
// Firefox can expose this heading without its text after Find, including
// with the original ancestry code. Check restored command ownership here;
// heading-label recovery is a separate regression (see browser-find.md).
waitSpeech("heading level 2", "heading")
key("ctrl+f")
waitSpeech("Find", "entry")
}