// 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" ) // Incremental games rebuild their text leaves on a fast timer. Arrow-key // reading must keep progressing through the rebuilt lines instead of // restarting at the first line after every tick, which is how the reported // Exotic Matter Dimensions and Ordinal Pringles failures presented. func TestNativeBrowserTickingNavigation(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BROWSER") != "1" { t.Skip("requires private browser session") } ctx, cancel := context.WithTimeout(context.Background(), 35*time.Second) defer cancel() page := `
After rebuilt region
Final paragraph
` server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, page) })) 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) } } command("key", "--clearmodifiers", "ctrl+l") command("type", "--clearmodifiers", "--delay", "0", 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 == "Ticking navigation regression" { 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.Start(ctx); err != nil { t.Fatal(err) } defer worker.Stop(context.Background()) worker.SyncFocus(document) next := func() (string, bool) { t.Helper() if err := worker.MoveDocumentCaret(review.CaretNextLine); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): got := result.DocumentCaretText if got == "" { got = result.DocumentPosition.Snapshot.Name } if result.Err != nil { t.Fatalf("next line on ticking page: %+v", result) } t.Logf("line=%q moved=%v elapsed=%s capture=%s reason=%v", got, result.Moved, result.Elapsed, result.CaptureDuration, result.CaptureReason) return got, result.Moved case <-ctx.Done(): t.Fatal(ctx.Err()) return "", false } } seen := []int{} previous := "" for range 14 { got, moved := next() if !moved { break } if got == previous && strings.HasPrefix(got, "Rebuilt line") { t.Fatalf("ticking line repeated instead of progressing: %q", got) } previous = got if line, ok := strings.CutPrefix(got, "Rebuilt line "); ok { var number int if _, err := fmt.Sscanf(line, "%d", &number); err == nil { seen = append(seen, number) // Let several ticks recreate every leaf before moving on. time.Sleep(700 * time.Millisecond) } continue } if strings.Contains(got, "After rebuilt region") { if fmt.Sprint(seen) != "[1 2 3]" { t.Fatalf("rebuilt lines visited = %v, want [1 2 3] in order", seen) } if final, _ := next(); !strings.Contains(final, "Final action") { t.Fatalf("line after rebuilt region = %q, want Final action", final) } return } } t.Fatalf("never reached text after the rebuilt region; rebuilt lines visited = %v", seen) }