// 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" "testing" "time" "git.stormux.org/storm/cthulhu/internal/atspi" "git.stormux.org/storm/cthulhu/internal/review" ) func TestNativeBrowserInlineReplacementNavigation(t *testing.T) { run_native_replacement_navigation(t, false) } func TestNativeBrowserGeneratedLinkNavigation(t *testing.T) { run_native_replacement_navigation(t, true) } func run_native_replacement_navigation(t *testing.T, generated bool) { t.Helper() 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) { if generated { fmt.Fprint(w, `Inline replacement regression

Published by Publisher some hours ago. Article continues

`) return } fmt.Fprint(w, `Inline replacement regression

Published↺ by PublisherArticle continues

`) })) 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 == "Inline replacement 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) dump_generated_tree := func() { if !generated { return } tree, captureErr := adapter.CaptureDocument(ctx, document) if captureErr != nil { return } ref, _ := tree.Reference(document) doc, _ := tree.Document(ctx, ref) for _, node := range doc.Nodes { if node.Snapshot.Role == "static" { text, err := adapter.ReadDocumentCaret(ctx, node.Reference, 0, atspi.DocumentCaretLine) t.Logf("native node %s parent=%s name=%q count=%d text=%+v err=%v", node.Snapshot.Path, node.Parent.Path, node.Snapshot.Name, node.Snapshot.CharacterCount, text, err) } } } move := func(command review.DocumentCaretCommand, want string) review.Result { t.Helper() if err := worker.MoveDocumentCaret(command); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): // Generated text can disappear between a successful virtual move // and native placement. A caret error must not undo that move; // require the exact text and subsequent navigation/replacement // checks rather than requiring the transient native caret to stick. if result.Err != nil || result.CaretErr != nil && !generated || !result.Moved || result.DocumentCaretText != want { dump_generated_tree() t.Fatalf("%s: text=%q want=%q err=%v caret=%v diagnostics=%+v", command, result.DocumentCaretText, want, result.Err, result.CaretErr, result.DocumentDiagnostics) } if generated && result.CaretErr != nil { t.Logf("generated native caret: %v; virtual movement succeeded", result.CaretErr) } return result case <-ctx.Done(): t.Fatal(ctx.Err()) return review.Result{} } } move(review.CaretNextLine, "Published") attribution, after := "↺ by Publisher", "Article continues" if generated { attribution, after = "↺ by Publisher some hours ago", ". Article continues" } current := move(review.CaretNextLine, attribution) for generation := 0; generation < 4; generation++ { old := current.DocumentPosition.Object if !generated { command("key", "--clearmodifiers", "F8") } // Require a genuinely replaced native static-text object. Merely // changing a label would not exercise recovery of a missing cursor. for { tree, err := adapter.CaptureDocument(ctx, document) if err != nil { t.Fatal(err) } if _, err := tree.Resolve(old); err != nil { break } select { case <-adapter.Events(): case <-ctx.Done(): t.Fatal("native attribution object was not replaced: ", ctx.Err()) } } if generation%2 == 0 { move(review.CaretNextLine, after) current = move(review.CaretPreviousLine, attribution) } else { move(review.CaretPreviousLine, "Published") current = move(review.CaretNextLine, attribution) } } if err := worker.ReadAll(ctx); err != nil { t.Fatal(err) } select { case result := <-worker.Results(): if result.Err != nil || len(result.ReadAllLines) != 2 { t.Fatalf("Read All after replacement: err=%v lines=%d", result.Err, len(result.ReadAllLines)) } for index, want := range []string{attribution, after} { if got := result.ReadAllLines[index].Contents[0].Text; got != want { dump_generated_tree() t.Fatalf("Read All line %d: %q, want %q", index, got, want) } } case <-ctx.Done(): t.Fatal(ctx.Err()) } }