// 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 TestNativeBrowserNestedDocumentHeadings(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) defer cancel() server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, `Nested document headings

First heading

`) for i := 0; i < 200; i++ { fmt.Fprint(w, `
`) for depth := 0; depth < 15; depth++ { fmt.Fprint(w, `
`) } fmt.Fprintf(w, `

Post %d

Post body %d

`, i, i) for depth := 0; depth < 15; depth++ { fmt.Fprint(w, `
`) } fmt.Fprint(w, `
`) } fmt.Fprint(w, `

Last heading

`) })) defer server.Close() adapter := atspi.New() 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) } } window, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "Cthulhu Browser Probe").Output() if err != nil || len(strings.Fields(string(window))) == 0 { t.Fatalf("browser window: %s: %v", window, err) } command("windowfocus", "--sync", strings.Fields(string(window))[0]) command("key", "--clearmodifiers", "ctrl+l") // The X window can appear before browser startup finishes. Wait for // native address-bar focus so startup cannot swallow the URL keystrokes. addressFocused := false retryFocus := time.NewTicker(time.Second) defer retryFocus.Stop() for !addressFocused { select { case event := <-adapter.Events(): addressFocused = event.Type == atspi.FocusedStateEvent && event.Detail1 == 1 && event.Source.Editable case <-retryFocus.C: command("key", "--clearmodifiers", "ctrl+l") case <-ctx.Done(): t.Fatal("browser address-bar focus: ", ctx.Err()) } } command("key", "--clearmodifiers", "ctrl+a") command("type", "--clearmodifiers", "--delay", "1", server.URL) command("key", "Return") var document atspi.ObjectID for !document.Valid() { select { case event := <-adapter.Events(): if event.Type == atspi.DocumentLoadCompleteEvent && event.Source.Name == "Nested document headings" { document = event.Source.ID() } case <-ctx.Done(): t.Fatal(ctx.Err()) } } worker, err := review.NewWorker(adapter, 2*time.Second) if err != nil { t.Fatal(err) } if err := worker.SetDocumentCaptureTimeout(documentCaptureTimeout); err != nil { t.Fatal(err) } if err := worker.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.SyncFocus(document) result := func(want string) { t.Helper() select { case result := <-worker.Results(): if result.CaptureDuration > 0 { t.Logf("whole-document capture: %v", result.CaptureDuration) } if result.Err != nil || result.DocumentPosition.Snapshot.Name != want { t.Fatalf("navigation = %q, want %q: err=%v capture=%v diagnostics=%+v", result.DocumentPosition.Snapshot.Name, want, result.Err, result.CaptureReason, result.DocumentDiagnostics) } if result.DocumentSoundErr != nil || !result.DocumentPosition.Snapshot.SoundGeometry.ObjectKnown || !result.DocumentPosition.Snapshot.SoundGeometry.ReferenceKnown { t.Fatalf("heading sound geometry unavailable: %+v, %v", result.DocumentPosition.Snapshot.SoundGeometry, result.DocumentSoundErr) } case <-ctx.Done(): t.Fatal(ctx.Err()) } } for _, step := range []struct { backwards bool want string }{ {false, "First heading"}, {true, "Last heading"}, {true, "Post 199"}, {false, "Last heading"}, {false, "First heading"}, } { if err := worker.MoveDocument(review.DocumentHeading, 0, step.backwards); err != nil { t.Fatal(err) } result(step.want) } }