// 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 TestNativeEmbeddedPanelApplet(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_PANEL_APPLET") != "1" { t.Skip("requires private Xorg/AT-SPI session") } // Embedding belongs to the window ancestry contract, not a process-name // exception. Both MATE and an ordinary GTK host must retain ownership. for _, hostName := range []string{"panel", "other-host"} { t.Run(hostName, func(t *testing.T) { check_native_embedded_applet(t, hostName) }) } } func check_native_embedded_applet(t *testing.T, hostName string) { 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(args ...string) (io.WriteCloser, string, string) { t.Helper() command := exec.CommandContext(ctx, "/usr/bin/python3", append([]string{"../../tests/gtk_panel_applet.py"}, args...)...) 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) } return input, fields[1], fields[2] } host, xid, socketID := start(hostName) applet, _, _ := start("applet", socketID) editor, editorXID, _ := start("editor") 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; filtered=%d last=%s %q window=%+v mismatch=%v", adapter.backgroundEventsFiltered.Load(), last.Type, last.Source.Name, last.Window, last.ForegroundMismatch) } } } key := func(args ...string) { t.Helper() if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool %v: %s, %v", args, output, err) } } key("windowfocus", "--sync", xid) fmt.Fprintln(host, "first") owned := wait(func(event Event) bool { return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch }) key("key", "Tab") icon := wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused }) if icon.ForegroundMismatch || icon.Window != owned.Window || icon.Source.BusName == owned.Source.BusName || icon.Foreground != owned.Foreground { t.Fatalf("embedded icon lost host ownership: host=%+v icon=%+v source=%+v mismatch=%v foreground=%+v want=%+v", owned.Window, icon.Window, icon.Source.ID(), icon.ForegroundMismatch, icon.Foreground, owned.Foreground) } if icon.Source.Name != "Network applet" || icon.Source.ApplicationProcess != "applet" { t.Fatalf("embedded icon lost its label or provider identity: %+v", icon) } key("key", "Tab") wait(func(event Event) bool { return event.Source.Name == "Applet settings" && event.Source.Focused && event.Window == owned.Window && !event.ForegroundMismatch }) key("key", "shift+Tab") wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch }) key("key", "Tab") wait(func(event Event) bool { return event.Source.Name == "Applet settings" && event.Source.Focused && !event.ForegroundMismatch }) key("key", "Tab") wait(func(event Event) bool { return event.Source.Name == "After applet" && event.Source.Focused && !event.ForegroundMismatch }) for _, name := range []string{"Applet settings", "Network applet", "Before applet"} { key("key", "shift+Tab") wait(func(event Event) bool { return event.Source.Name == name && event.Source.Focused && !event.ForegroundMismatch }) } key("windowfocus", "--sync", editorXID) fmt.Fprintln(editor, "edit Foreground restored") wait(func(event Event) bool { return event.Payload == "Foreground restored" && !event.ForegroundMismatch }) fmt.Fprintln(applet, "stale") wait(func(event Event) bool { if event.Source.Role == "icon" && !event.ForegroundMismatch { t.Fatalf("background applet stole foreground: %+v", event) } return event.Payload == "Applet barrier" }) // A separately published applet window cannot borrow the host's identity. standalone, standaloneXID, _ := start("standalone") key("windowfocus", "--sync", standaloneXID) fmt.Fprintln(standalone, "first") wait(func(event Event) bool { return event.Source.Role == "icon" && event.Source.Focused && !event.ForegroundMismatch }) key("windowfocus", "--sync", xid) fmt.Fprintln(host, "first") wait(func(event Event) bool { return event.Source.Name == "Before applet" && event.Source.Focused && !event.ForegroundMismatch }) fmt.Fprintln(standalone, "stale") wait(func(event Event) bool { if event.Source.Role == "icon" && !event.ForegroundMismatch { t.Fatalf("unembedded applet borrowed host ownership: %+v", event) } return event.Payload == "Applet barrier" }) fmt.Fprintln(host, "detach") wait(func(event Event) bool { return event.Payload == "Detached host barrier" }) fmt.Fprintln(applet, "stale") wait(func(event Event) bool { if event.Source.Role == "icon" && !event.ForegroundMismatch { t.Fatalf("detached applet retained host ownership: %+v", event) } return event.Payload == "Applet barrier" }) }