// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package atspi import ( "context" "os" "os/exec" "strings" "testing" "time" ) func TestNativeGTKAlertMessage(t *testing.T) { window := os.Getenv("CTHULHU_ALERT_WINDOW") if window == "" { t.Skip("requires the private GTK alert fixture") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() for _, args := range [][]string{{"windowfocus", window}, {"key", "--clearmodifiers", "u"}} { if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool %s: %v: %s", args[0], err, output) } } for { select { case <-ctx.Done(): t.Fatal("GTK alert window:create with its message was not captured") case event := <-adapter.Events(): if event.Type != WindowCreateEvent || event.Source.Role != "alert" { continue } if event.Source.Text != "Version 0.6.0 is available. Open the releases page in your browser?" { t.Fatalf("alert text = %q", event.Source.Text) } return } } } func TestNativeYADQuestionMessage(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() command := exec.CommandContext(ctx, "yad", "--image", "dialog-question", "--title", "Alert", "--button=gtk-yes:0", "--button=gtk-no:1", "--text", "Microsoft Windows has been found! Would you like to remove it?") if err := command.Start(); err != nil { t.Fatal(err) } defer func() { _ = command.Process.Kill() _ = command.Wait() }() var yadWindow string for yadWindow == "" && ctx.Err() == nil { output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Alert$").Output() if err == nil { yadWindow = strings.TrimSpace(string(output)) } if yadWindow == "" { time.Sleep(20 * time.Millisecond) } } if yadWindow == "" { t.Fatal("YAD window did not appear") } if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil { t.Fatalf("focus YAD window: %v: %s", err, output) } for { select { case <-ctx.Done(): t.Fatal("YAD question was not captured on activation or button focus") case event := <-adapter.Events(): question := "Microsoft Windows has been found! Would you like to remove it?" if event.Type == WindowActivatedEvent && event.Source.Role == "frame" && event.Source.Name == "Alert" { if !event.Window.Valid() || event.Window != event.Source.ID() { t.Fatalf("YAD frame ownership = %#v, source = %#v", event.Window, event.Source.ID()) } if event.Source.Text == question { return } if output, err := exec.CommandContext(ctx, "xdotool", "key", "--clearmodifiers", "Tab").CombinedOutput(); err != nil { t.Fatalf("focus YAD button: %v: %s", err, output) } } if (event.Type == FocusEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.Source.Role == "button" && event.WindowMessage == question { if !event.Window.Valid() || event.Window == event.Source.ID() { t.Fatalf("YAD button ownership = %#v, source = %#v", event.Window, event.Source.ID()) } return } } } } func TestNativeYADListMessage(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() command := exec.CommandContext(ctx, "yad", "--list", "--title=Select Sound Type", "--text=Which sound type would you like to use?", "--column=Option", "--no-headers", "--selectable-labels", "--search-column=1", "--height=400", "--width=600") command.Stdin = strings.NewReader("stereo\nhrtf\n") if err := command.Start(); err != nil { t.Fatal(err) } defer func() { _ = command.Process.Kill() _ = command.Wait() }() var yadWindow string for yadWindow == "" && ctx.Err() == nil { output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Select Sound Type$").Output() if err == nil { yadWindow = strings.TrimSpace(string(output)) } if yadWindow == "" { time.Sleep(20 * time.Millisecond) } } if yadWindow == "" { t.Fatal("YAD list window did not appear") } if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(yadWindow, "\n")[0]).CombinedOutput(); err != nil { t.Fatalf("focus YAD list window: %v: %s", err, output) } for { select { case <-ctx.Done(): t.Fatal("YAD list question was not captured on activation") case event := <-adapter.Events(): if event.Source.ApplicationName != "yad" || (event.Type != WindowActivatedEvent && event.Type != FocusEvent && (event.Type != FocusedStateEvent || event.Detail1 != 1)) { continue } if event.Source.Role != "tree table" && event.Source.Role != "frame" { continue } question := event.WindowMessage if event.Source.Role == "frame" && event.Type == WindowActivatedEvent { question = event.Source.Text } if question == "" { continue } if question != "Which sound type would you like to use?" { t.Fatalf("YAD list question = %q on %q activation", question, event.Source.Role) } return } } } func TestNativeYADPowerListMessage(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_YAD") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() command := exec.CommandContext(ctx, "yad", "--title", "I38", "--text", "Power options", "--list", "--column", "Action", "--column", "Description", "Power off", "Shut down the system", "Reboot", "Restart the system", "Log out", "Exit the current session", "--print-column=1", "--separator=", "--button", "Select:0", "--button", "Cancel:1") if err := command.Start(); err != nil { t.Fatal(err) } defer func() { _ = command.Process.Kill() _ = command.Wait() }() var window string for window == "" && ctx.Err() == nil { output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^I38$").Output() if err == nil { window = strings.TrimSpace(string(output)) } if window == "" { time.Sleep(20 * time.Millisecond) } } if window == "" { t.Fatal("I38 list did not appear") } if output, err := exec.CommandContext(ctx, "xdotool", "windowfocus", strings.Split(window, "\n")[0]).CombinedOutput(); err != nil { t.Fatalf("focus I38 list: %v: %s", err, output) } for { select { case <-ctx.Done(): t.Fatal("I38 power prompt was not captured") case event := <-adapter.Events(): if event.Source.ApplicationName == "yad" && (event.Source.Role == "tree table" || event.Source.Role == "table cell") && (event.Type == WindowActivatedEvent || event.Type == FocusedStateEvent && event.Detail1 == 1) && event.WindowMessage == "Power options" { return } } } } func TestNativeGTKRadioGroupName(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_RADIO") != "1" { t.Skip("requires private Xvfb/AT-SPI session") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() adapter := New() if err := adapter.Start(ctx); err != nil { t.Fatal(err) } defer func() { stopCtx, stopCancel := context.WithTimeout(context.Background(), 3*time.Second) defer stopCancel() if err := adapter.Stop(stopCtx); err != nil { t.Errorf("stop adapter: %v", err) } }() command := exec.CommandContext(ctx, "/usr/bin/python3", "../../tests/gtk_radio_group.py") if err := command.Start(); err != nil { t.Fatal(err) } defer func() { _ = command.Process.Kill() _ = command.Wait() }() var window string for window == "" && ctx.Err() == nil { output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Radio group probe$").Output() if err == nil { window = strings.TrimSpace(string(output)) } if window == "" { time.Sleep(20 * time.Millisecond) } } if window == "" { t.Fatal("GTK radio window did not appear") } for _, args := range [][]string{{"windowfocus", strings.Split(window, "\n")[0]}, {"key", "Tab"}} { if output, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("xdotool %s: %v: %s", args[0], err, output) } } for { select { case <-ctx.Done(): t.Fatal("GTK radio group name was not captured") case event := <-adapter.Events(): if event.Source.Role == "radio button" && event.Source.Focused { if event.Source.RadioGroupName != "Punctuation Level" { t.Fatalf("radio group = %q", event.Source.RadioGroupName) } return } } } }