Files
cthulhu/internal/atspi/panel_menu_native_test.go

189 lines
5.6 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"
"io"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestNativeMatePanelMenuOutsideForeground(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_PANEL_MENU") != "1" {
t.Skip("requires private Xorg/AT-SPI session")
}
for _, test := range []struct {
name, processName string
empty bool
}{
{"over editor", "mate-panel", false},
{"empty desktop", "mate-panel", true},
{"impostor", "impostor", false},
} {
t.Run(test.name, func(t *testing.T) { check_native_panel_menu(t, test.processName, test.empty) })
}
}
func check_native_panel_menu(t *testing.T, processName string, empty bool) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
adapter := New()
if err := adapter.Start(ctx); err != nil {
t.Fatal(err)
}
defer adapter.Stop(context.Background())
start := func(name string) (io.WriteCloser, string) {
t.Helper()
command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_panel_menu.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() })
line, err := bufio.NewReader(output).ReadString('\n')
fields := strings.Fields(line)
if err != nil || len(fields) != 2 || fields[0] != "ready" {
t.Fatalf("fixture readiness: %q, %v", line, err)
}
return input, fields[1]
}
panel, _ := start(processName)
editor, xid := start("editor")
tree, err := adapter.CaptureDesktop(ctx)
if err != nil {
t.Fatal(err)
}
check_native_panel_icons(t, tree)
key := func(args ...string) {
t.Helper()
if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("xdotool: %s, %v", output, err)
}
}
key("windowfocus", "--sync", xid)
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("native event missing: %v; filtered=%d last=%+v", ctx.Err(), adapter.backgroundEventsFiltered.Load(), last)
}
}
}
fmt.Fprintln(editor, "edit Foreground ready")
owned := wait(func(event Event) bool { return event.Payload == "Foreground ready" && !event.ForegroundMismatch })
if empty {
key("windowfocus", "0")
for adapter.Foreground().Client.Window != 0 {
select {
case <-adapter.ForegroundChanges():
case <-ctx.Done():
t.Fatal("empty desktop foreground missing")
}
}
owned.Foreground = adapter.Foreground()
}
fmt.Fprintln(panel, "popup")
activation := wait(func(event Event) bool {
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
})
if processName != "mate-panel" {
if !activation.ForegroundMismatch {
t.Fatalf("accessible name enabled panel exception for another process: %+v", activation)
}
key("key", "Escape")
return
}
if activation.ForegroundMismatch || activation.Foreground != owned.Foreground {
t.Fatalf("panel popup rejected with unchanged X11 foreground: %+v", activation)
}
for _, step := range []struct{ key, name string }{
{"Down", "First application"}, {"Down", "Utilities"}, {"Right", "Example utility"},
{"Left", "Utilities"}, {"Down", "Last application"}, {"Up", "Utilities"},
} {
key("key", step.key)
event := wait(func(event Event) bool {
return event.Source.Name == step.name && (event.Type == FocusEvent || event.Type == FocusedStateEvent || event.Type == SelectionChangedEvent)
})
if event.ForegroundMismatch || event.Foreground != owned.Foreground || event.Source.ApplicationProcess != "mate-panel" {
t.Fatalf("panel navigation lost ownership: %+v", event)
}
}
key("key", "Escape")
fmt.Fprintln(panel, "ordinary")
ordinary := wait(func(event Event) bool {
return event.Type == WindowActivatedEvent && event.Source.ApplicationName == "mate-panel"
})
if !ordinary.ForegroundMismatch {
t.Fatalf("ordinary panel window borrowed menu exception: %+v", ordinary)
}
fmt.Fprintln(panel, "stale")
wait(func(event Event) bool {
if event.Source.Name == "First application" && (event.Type == FocusEvent || event.Type == FocusedStateEvent) {
t.Fatalf("hidden menu item escaped filtering: %+v", event)
}
return event.Payload == "Stale barrier"
})
if empty {
key("windowfocus", "--sync", xid)
}
fmt.Fprintln(editor, "edit Foreground restored")
restored := wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch })
if restored.Window != owned.Window || !empty && restored.Foreground != owned.Foreground {
t.Fatalf("panel dismissal changed editor ownership: %+v", restored)
}
}
func check_native_panel_icons(t *testing.T, tree *AccessibleTree) {
t.Helper()
tree.mu.RLock()
defer tree.mu.RUnlock()
icons := 0
for _, node := range tree.nodes {
icon := node.snapshot
if icon.Role != "icon" {
continue
}
icons++
parent := tree.nodes[node.parent].snapshot
want := ""
switch parent.Name {
case "Network applet":
want = parent.Name
case "Unused applet name":
if icon.Description == "" {
want = "Named icon"
}
}
if icon.Name != want {
t.Fatalf("panel icon name=%q description=%q parent=%q; want %q", icon.Name, icon.Description, parent.Name, want)
}
}
if icons != 8 {
t.Fatalf("captured %d panel icons; want four cases from each provider", icons)
}
}