140 lines
4.7 KiB
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"
|
|
})
|
|
}
|