// 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") }