// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package app import ( "context" "fmt" "net/http" "net/http/httptest" "os" "os/exec" "strings" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "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 := `
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) } 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) { t.Helper() for { select { case event := <-adapter.Events(): if event.Type == atspi.AccessibleNameEvent && event.Source.Name == name { return } case <-ctx.Done(): t.Fatalf("name change to %q was not observed", name) } } } waitName("Counter updated") 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") for _, unit := range []review.FlatUnit{review.WindowEnd, review.Line} { started := time.Now() step := 0 if unit == review.Line { step = -1 } if err := worker.ReadFlat(unit, step); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): t.Logf("flat review unit %d elapsed=%s capture=%s reason=%v", unit, time.Since(started), result.CaptureDuration, result.CaptureReason) if result.Err != nil || strings.TrimSpace(result.FlatPosition.Text) == "" || unit == review.Line && !result.Moved { t.Fatalf("large-page flat review unit %d = %+v", unit, result) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } }