Files
cthulhu/internal/atspi/table_refresh_native_test.go

140 lines
4.7 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package atspi
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestNativeGUITableRefresh(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_TABLE_REFRESH") != "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())
flags, err := exec.CommandContext(ctx, "pkg-config", "--cflags", "--libs", "atspi-2").Output()
if err != nil {
t.Fatal(err)
}
library := filepath.Join(t.TempDir(), "refresh-signals.so")
args := append([]string{"-shared", "-fPIC", "-o", library, "../../tests/atspi_refresh_signals.c"}, strings.Fields(string(flags))...)
if output, err := exec.CommandContext(ctx, "cc", args...).CombinedOutput(); err != nil {
t.Fatalf("compile signal fixture: %v: %s", err, output)
}
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_table_refresh.py", library)
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() })
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 3 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", "--sync", fields[1]).CombinedOutput(); err != nil {
t.Fatalf("focus: %s, %v", output, err)
}
wait := func(predicate func(Event) bool) Event {
t.Helper()
var last Event
for {
select {
case event := <-adapter.Events():
last = event
if predicate(event) {
return event
}
case <-ctx.Done():
t.Fatalf("event missing: %v; last=%s %q", ctx.Err(), last.Type, last.Source.Name)
}
}
}
initial := wait(func(event Event) bool {
return event.Source.Name == "Original row 0" && event.Source.GUITable && !event.ForegroundMismatch
})
// Populate libatspi's cells so the retired objects are known before the
// provider emits stale claims about them, as in a previously read timeline.
if initial.Source.BusName != fields[2] {
t.Fatal("signal fixture is not using GTK's provider connection")
}
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
var oldPaths []string
tree.mu.RLock()
for id, entry := range tree.nodes {
if strings.HasPrefix(entry.snapshot.Name, "Original row ") {
oldPaths = append(oldPaths, id.Path)
}
}
tree.mu.RUnlock()
if len(oldPaths) != 300 {
t.Fatalf("cached %d original cells, want 300", len(oldPaths))
}
fmt.Fprintln(input, "refresh", initial.Source.TablePath, strings.Join(oldPaths, ","))
invalidEvents, selectionEvents := 0, 0
finalSelection := false
wait(func(event Event) bool {
if event.Type != DefunctStateEvent && (event.Source.Role == "invalid" || event.Source.Defunct) {
invalidEvents++
}
if event.Type == SelectionChangedEvent {
selectionEvents++
finalSelection = finalSelection || event.Source.Name == "Updated row 7" && !event.ForegroundMismatch
}
return event.Type == TextInsertedEvent && event.Payload == "Refresh complete"
})
if !finalSelection {
t.Error("refresh did not deliver the final selected row")
}
if invalidEvents != 0 {
t.Errorf("captured %d obsolete cell events during refresh", invalidEvents)
}
if selectionEvents > 20 {
t.Errorf("captured %d selection events for one synchronous refresh", selectionEvents)
}
t.Logf("refresh: %d invalid captures, %d selection captures", invalidEvents, selectionEvents)
for _, row := range []int{8, 9, 8} {
fmt.Fprintln(input, "select", row)
wait(func(event Event) bool {
return event.Source.Name == fmt.Sprint("Updated row ", row) && event.Source.GUITable && !event.ForegroundMismatch
})
}
// A table update immediately followed by editor focus must not restore the
// old row later when a deferred selection callback finally runs.
fmt.Fprintln(input, "select 9\nentry")
wait(func(event Event) bool { return event.Source.Name == "After table" && event.Source.Focused })
fmt.Fprintln(input, "barrier")
wait(func(event Event) bool {
if event.Type == SelectionChangedEvent || event.Type == ActiveDescendantEvent {
t.Errorf("table event delivered after editor focus: %s %q", event.Type, event.Source.Name)
}
return event.Type == TextInsertedEvent && event.Payload == "Focus boundary complete"
})
}