// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "fmt" "log/slog" "net/http" "net/http/httptest" "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/review" ) func TestNativeBrowserDynamicNavigation(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() page := `Dynamic navigation regression Start of page

After control

` // A large document must remain navigable without exhausting the live // two-second first-capture budget before any browse position exists. for i := 0; i < 1000; i++ { page += fmt.Sprintf("

Unrelated paragraph %d

", i) } page += `

End of page

` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, page) })) defer server.Close() adapter := atspi.New() adapter.SetCaptureSoundGeometry(true) if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) command := func(args ...string) { t.Helper() if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool: %v: %s", err, output) } } command("key", "--clearmodifiers", "ctrl+l") command("type", "--clearmodifiers", "--delay", "0", server.URL) command("key", "Return") var document atspi.ObjectID var applicationName string for !document.Valid() { select { case event := <-adapter.Events(): if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Dynamic navigation regression" { document = event.Source.ID() applicationName = event.Source.ApplicationName } case <-ctx.Done(): t.Fatal(ctx.Err()) } } worker, err := review.NewWorker(adapter, 2*time.Second) if err != nil { t.Fatal(err) } if err := worker.SetFlatCaptureTimeout(10 * time.Second); err != nil { t.Fatal(err) } if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.SyncFocus(document) move := func(command review.DocumentCaretCommand, want string) { t.Helper() if err := worker.MoveDocumentCaret(command); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): got := result.DocumentCaretText if got == "" { got = result.DocumentPosition.Snapshot.Name } if result.Err != nil || result.CaretErr != nil || !result.Moved || !strings.Contains(got, want) { t.Fatalf("next line = %q, want %q: %+v", got, want, result) } if result.DocumentPosition.Snapshot.ApplicationName != applicationName { t.Fatalf("document target application = %q, want %q", result.DocumentPosition.Snapshot.ApplicationName, applicationName) } attempts := result.DocumentDiagnostics.Attempts if len(attempts) == 0 || attempts[len(attempts)-1].After.Object != result.DocumentPosition.Object.ID() { t.Fatalf("navigation diagnostics do not match the browser position: %+v", result.DocumentDiagnostics) } t.Logf("line=%q elapsed=%s capture=%s reason=%v", got, result.Elapsed, result.CaptureDuration, result.CaptureReason) case <-ctx.Done(): t.Fatal(ctx.Err()) } } next := func(want string) { t.Helper() move(review.CaretNextLine, want) } next("Start of page") next("Counter") waitName := func(name string) atspi.Event { t.Helper() for { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name { return event } case <-ctx.Done(): t.Fatalf("name change to %q was not observed", name) } } } counter := waitName("Counter updated") // Exercise actual browser name events at the reported update cadence. // The separate event-loop regression covers routing and immediate Braille. state := &eventLoopState{} state.current, state.focusedID, state.owner.window = counter.Source, counter.Source.ID(), counter.Window browser := newBrowserModeController(nil, &modeKeys{}, nil) browser.ready, browser.focus, browser.document = true, counter.Source.ID(), document namePresentations := 0 handler := &accessibilityEventHandler{state: state, browser: browser, logger: slog.New(slog.DiscardHandler), speak: func(event atspi.Event, _ ...input.Intent) { namePresentations++ if event.Source.Name != "Counter 6" { t.Fatalf("changing native counter spoke before settling: %q", event.Source.Name) } }} defer state.pending.clear() updates := 0 for namePresentations == 0 { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && event.Source.ID() == counter.Source.ID() { updates++ state.current = event.Source if !handler.defer_browser_button_name(event, input.Activity{}) { t.Fatal("native web button name was not coalesced") } } case <-state.pending.browserButtonName.ready: handler.flush_browser_button_name() case <-ctx.Done(): t.Fatal("native counter did not finish: ", ctx.Err()) } } if updates < 2 { t.Fatalf("observed only %d native counter updates", updates) } t.Logf("coalesced %d native counter updates into one settled announcement", updates) next("After control") next("Avatar") next("Add content") waitName("Add content done") next("Unrelated paragraph 0") next("Unrelated paragraph 1") move(review.CaretDocumentEnd, "New content") // Browsing to the end scrolls paragraphs which were previously not // showing into view. Flat review must capture those paragraphs even if // the provider's state cache still describes the initial viewport. for index, tc := range []struct { unit review.FlatUnit step int text string moved bool }{ {review.WindowEnd, 0, "New content", true}, {review.Line, -1, "End of page", true}, {review.Line, -1, "Unrelated paragraph 999", true}, {review.Line, 1, "End of page", true}, {review.Word, 1, "of", true}, {review.Character, 1, "f", true}, {review.WindowEnd, 0, "New content", true}, {review.Character, 0, "t", false}, {review.Line, 1, "New content", false}, {review.Line, -1, "End of page", true}, } { started := time.Now() if err := worker.ReadFlat(tc.unit, tc.step); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): t.Logf("flat review unit %d step=%d text=%q moved=%v elapsed=%s capture=%s reason=%v", tc.unit, tc.step, result.FlatPosition.Text, result.Moved, time.Since(started), result.CaptureDuration, result.CaptureReason) if result.Err != nil || strings.TrimSpace(result.FlatPosition.Text) != tc.text || index > 0 && result.Moved != tc.moved { t.Fatalf("large-page flat review unit %d step=%d want=%q moved=%v: %+v", tc.unit, tc.step, tc.text, tc.moved, result) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } }