// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "errors" "fmt" "os" "os/exec" "testing" "time" ) func TestNativeBrowserMissingFeedRefreshesWholeBranch(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() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer adapter.Stop(context.Background()) keys := func(args ...string) { t.Helper() if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool: %v: %s", err, output) } } desktop, err := adapter.CaptureDesktop(ctx) if err != nil { t.Fatal(err) } start := findNamedReference(t, desktop, "Feed start") bounds, err := desktop.Geometry(start) if err != nil || !bounds.BoundsKnown { t.Fatalf("feed start bounds unavailable: %+v, %v", bounds, err) } keys("key", "--clearmodifiers", "ctrl+l") keys("mousemove", "--sync", fmt.Sprint(bounds.Bounds.X+bounds.Bounds.Width/2), fmt.Sprint(bounds.Bounds.Y+bounds.Bounds.Height/2), "click", "1") focused := false for !focused { select { case event := <-adapter.Events(): focused = event.Type == FocusedStateEvent && event.Detail1 == 1 && event.Source.ID() == start.ID() case <-ctx.Done(): t.Fatal("feed start focus: ", ctx.Err()) } } tree, err := adapter.CaptureDocument(ctx, start.ID()) if err != nil { t.Fatal(err) } root, _ := tree.Root() doc, err := tree.Document(ctx, root) if err != nil { t.Fatal(err) } // Keep the captured semantics from before replacement. This explicitly // models a provider omitting the structural events for an enclosing feed. oldNodes := make([]TreeNode, 0, len(doc.Nodes)) for _, node := range doc.Nodes { oldNodes = append(oldNodes, TreeNode{Snapshot: node.Snapshot, Children: node.Children, Geometry: node.Geometry, Text: node.Text}) } missing := findNamedReference(t, tree, "Later post") otherOld := findNamedReference(t, tree, "Earlier post") keys("key", "--clearmodifiers", "F9") replaced := false for !replaced { select { case event := <-adapter.Events(): replaced = event.Type == AccessibleNameEvent && event.Source.Name == "Feed replacement complete" case <-ctx.Done(): t.Fatal("feed replacement: ", ctx.Err()) } } if err := tree.Replace(root.ID(), oldNodes); err != nil { t.Fatal(err) } // Use the current generation after restoring the deliberately stale cache. missing, err = tree.Reference(missing.ID()) if err != nil { t.Fatal(err) } _, err = adapter.ReadDocumentCaret(ctx, missing, 0, DocumentCaretBrailleLine) if !errors.Is(err, ErrDeadObject) { t.Fatalf("missing feed target = %v, want confirmed removal", err) } // libatspi can dispatch late defunct events during the failed native // read. Restore the omitted-event scenario after that call, then replay // its genuine missing-object response against the stale owned snapshot. if restoreErr := tree.Replace(root.ID(), oldNodes); restoreErr != nil { t.Fatal(restoreErr) } if replayErr := adapter.document_object_error(missing.ID(), err); !errors.Is(replayErr, ErrDeadObject) { t.Fatal(replayErr) } tree, err = adapter.CaptureDocument(ctx, start.ID()) if err != nil { t.Fatal(err) } if _, err := tree.Reference(otherOld.ID()); !errors.Is(err, ErrObjectNotFound) { t.Fatalf("recapture retained another dead heading: %v", err) } root, err = tree.Root() if err != nil { t.Fatal(err) } fresh, err := tree.Document(ctx, root) if err != nil { t.Fatal(err) } expected := []string{"Later refreshed post", "Middle refreshed post", "Earlier refreshed post"} headingIndex := 0 for index := len(fresh.Nodes) - 1; index >= 0; index-- { node := fresh.Nodes[index] if node.Snapshot.Role != "heading" { continue } if headingIndex >= len(expected) || node.Snapshot.Name != expected[headingIndex] { t.Fatalf("unexpected backward heading %q at index %d", node.Snapshot.Name, headingIndex) } text, err := adapter.ReadDocumentCaret(ctx, node.Reference, 0, DocumentCaretBrailleLine) if err != nil || text.Text != expected[headingIndex] { t.Fatalf("backward feed reading %q = %+v, %v", expected[headingIndex], text, err) } headingIndex++ } if headingIndex != len(expected) { t.Fatalf("backward headings = %d, want %d", headingIndex, len(expected)) } }