// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "bufio" "context" "fmt" "io" "os" "os/exec" "strings" "testing" "time" "github.com/godbus/dbus/v5" ) func TestBackgroundInvalidationPreservesOtherProviderLines(t *testing.T) { owned := NewObjectID(":1.42", "/line") other := NewObjectID(":1.43", "/line") adapter := New() adapter.documentLineCache = map[ObjectID]DocumentCaretText{owned: {}, other: {}} adapter.invalidateTreeEvent(TextInsertedEvent, 0, other, ObjectID{}) if _, ok := adapter.documentLineCache[owned]; !ok { t.Fatal("background text change cleared the owned provider's line cache") } if _, ok := adapter.documentLineCache[other]; ok { t.Fatal("changed provider's line cache survived invalidation") } adapter.invalidateTreeEvent(AccessibleNameEvent, 0, owned, ObjectID{}) if len(adapter.documentLineCache) != 0 { t.Fatal("owned provider change failed to invalidate its lines") } } func TestNativeBackgroundEventFiltering(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_BACKGROUND_EVENTS") != "1" { t.Skip("requires private Xorg/AT-SPI 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()) type fixture struct { input io.WriteCloser output *bufio.Reader xid string } start := func(name string) fixture { command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_background_events.py", name) input, err := command.StdinPipe() if err != nil { t.Fatal(err) } output, err := command.StdoutPipe() if err != nil { t.Fatal(err) } command.Stderr = os.Stderr if err := command.Start(); err != nil { t.Fatal(err) } t.Cleanup(func() { input.Close(); command.Process.Kill(); command.Wait() }) reader := bufio.NewReader(output) line, err := reader.ReadString('\n') fields := strings.Fields(line) if err != nil || len(fields) != 2 || fields[0] != "ready" { t.Fatalf("fixture readiness: %q, %v", line, err) } return fixture{input, reader, fields[1]} } wait := func(predicate func(Event) bool) Event { t.Helper() var last Event count := 0 for { select { case event := <-adapter.Events(): last = event count++ if predicate(event) { return event } case <-ctx.Done(): t.Fatalf("native event missing: %v; filtered=%d received=%d last=%s %s %q", ctx.Err(), adapter.backgroundEventsFiltered.Load(), count, last.Type, last.Source.Role, last.Source.Name) } } } focus := func(xid string) { t.Helper() if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", xid).CombinedOutput(); err != nil { t.Fatalf("focus: %s, %v", output, err) } } background := start("Background fixture") foreground := start("Foreground fixture") // Publish the complete AT-SPI cache, including table cells, before flooding. if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } focus(foreground.xid) fmt.Fprintln(foreground.input, "edit") owned := wait(func(event Event) bool { return event.Type == TextInsertedEvent && event.Payload == "Updated text" && !event.ForegroundMismatch }) sessionBus, err := dbus.ConnectSessionBus() if err != nil { t.Fatal(err) } defer sessionBus.Close() var address string if err := sessionBus.Object("org.a11y.Bus", "/org/a11y/bus").CallWithContext(ctx, "org.a11y.Bus.GetAddress", 0).Store(&address); err != nil { t.Fatal(err) } control, err := dbus.Connect(address) if err != nil { t.Fatal(err) } defer control.Close() monitor, err := dbus.Connect(address) if err != nil { t.Fatal(err) } defer monitor.Close() if err := monitor.BusObject().CallWithContext(ctx, "org.freedesktop.DBus.Monitoring.BecomeMonitor", 0, []string{"type='method_call'"}, uint32(0)).Err; err != nil { t.Fatal(err) } messages := make(chan *dbus.Message, 65536) monitor.Eavesdrop(messages) flood := func(generation string, sameProvider bool) { t.Helper() before := adapter.backgroundEventsFiltered.Load() fmt.Fprintln(background.input, "flood "+generation) marker := wait(func(event Event) bool { return event.Type == TextInsertedEvent && event.Payload == "Flood complete "+generation }) if (marker.Source.BusName == owned.Source.BusName) != sameProvider { t.Fatal("fixture provider identity did not match test case") } if count := adapter.backgroundEventsFiltered.Load() - before; count < 300 { t.Fatalf("filtered %d events; want at least 300 real selection updates", count) } if err := control.Object(marker.Source.BusName, dbus.ObjectPath(marker.Source.Path)).CallWithContext(ctx, "org.freedesktop.DBus.Peer.Ping", 0).Err; err != nil { t.Fatal(err) } queries := 0 reading: for { select { case message := <-messages: if message.Headers[dbus.FieldDestination].Value() != marker.Source.BusName { continue } member := message.Headers[dbus.FieldMember].Value() if member == "Ping" { break reading } if message.Headers[dbus.FieldPath].Value() == dbus.ObjectPath(marker.Source.Path) { continue } switch message.Headers[dbus.FieldInterface].Value() { case "org.a11y.atspi.Selection", "org.a11y.atspi.Table", "org.a11y.atspi.Text", "org.a11y.atspi.Value": queries++ } case <-ctx.Done(): t.Fatal("monitor barrier missing") } } if queries != 0 { t.Fatalf("background flood caused %d selection/table/text/value queries", queries) } t.Logf("%s: filtered at least 300 updates with zero content queries", generation) } flood("other-provider", false) // The same application can have an inactive sibling on the same bus. fmt.Fprintln(background.input, "sibling") line, err := background.output.ReadString('\n') fields := strings.Fields(line) if err != nil || len(fields) != 2 || fields[0] != "sibling" { t.Fatalf("sibling readiness: %q, %v", line, err) } if _, err := adapter.CaptureDesktop(ctx); err != nil { t.Fatal(err) } focus(fields[1]) owned = wait(func(event Event) bool { return !event.ForegroundMismatch && event.Source.Name == "Background fixture sibling entry" }) flood("same-provider", true) // Returning to the previously noisy window must read its current selection. focus(background.xid) fmt.Fprintln(background.input, "focus-table") wait(func(event Event) bool { return !event.ForegroundMismatch && event.Source.Name == "Result 1" && event.Source.GUITable }) t.Log("returning to the noisy application delivers current table focus") }