// 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) } }