Find works in browsers.
This commit is contained in:
1 parent
1074904c5d
commit
ac67236eca
8 files changed
+307
-7
No files matched your search
@@ -0,0 +1,224 @@
|
||||
// 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")
|
||||
}
|
||||
Reference in new issue
Block a user