Files

207 lines
6.0 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package xorg
import (
"context"
"os"
"os/exec"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
)
func TestNativeInternalUICapturesAndReleases(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_X11") != "1" {
t.Skip("requires isolated X11 display")
}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
log, err := os.CreateTemp(t.TempDir(), "keys-")
if err != nil {
t.Fatal(err)
}
defer log.Close()
probe := exec.CommandContext(ctx, "stdbuf", "-oL", "xev", "-name", "Internal UI probe", "-event", "keyboard")
probe.Stdout = log
if err := probe.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }()
var window string
for window == "" {
data, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Internal UI probe$").Output()
if err == nil {
window = strings.TrimSpace(string(data))
}
if ctx.Err() != nil {
t.Fatal("probe did not open")
}
time.Sleep(10 * time.Millisecond)
}
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
t.Fatal(err)
}
listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
key := func(names ...string) {
t.Helper()
args := append([]string{"key"}, names...)
if data, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil {
t.Fatalf("keys %v: %v %s", names, err, data)
}
}
want := func(command input.Command) input.CommandEvent {
t.Helper()
select {
case event := <-listener.Commands():
if event.Command != command {
t.Fatalf("command=%s, want %s", event.Command, command)
}
return event
case err := <-listener.Failures():
t.Fatal(err)
case <-ctx.Done():
t.Fatalf("missing %s", command)
}
return input.CommandEvent{}
}
key("Insert+shift+a")
opened := want(input.ShowActions)
if opened.UISession == 0 || listener.InternalUISession() != opened.UISession {
t.Fatal("actions did not capture atomically")
}
for _, name := range []string{"Down", "Up", "shift+a", "exclam", "U00e9", "Return"} {
key(name)
event := want(input.InternalUIKey)
if event.UISession != opened.UISession {
t.Fatal("wrong session")
}
if name == "shift+a" && event.Text != "A" {
t.Fatalf("shift translation=%q", event.Text)
}
if name == "exclam" && event.Text != "!" {
t.Fatalf("punctuation=%q", event.Text)
}
if name == "U00e9" && event.Text != "é" {
t.Fatalf("unicode=%q", event.Text)
}
}
if focus, err := exec.CommandContext(ctx, "xdotool", "getwindowfocus").Output(); err != nil || strings.TrimSpace(string(focus)) != window {
t.Fatal("capture changed application focus")
}
key("Escape")
want(input.InternalUIKey)
for listener.InternalUIEchoActive() {
if ctx.Err() != nil {
t.Fatal("Escape did not release")
}
time.Sleep(time.Millisecond)
}
key("y")
// An independently opened form uses the same ownership and stale-close rules.
second, err := listener.BeginInternalUI(ctx)
if err != nil {
t.Fatal(err)
}
if err := listener.EndInternalUI(ctx, opened.UISession); err != nil {
t.Fatal(err)
}
if listener.InternalUISession() != second {
t.Fatal("stale close revoked new form")
}
key("z")
want(input.InternalUIKey)
if err := exec.CommandContext(ctx, "xdotool", "keydown", "Return").Run(); err != nil {
t.Fatal(err)
}
want(input.InternalUIKey)
closed := make(chan error, 1)
go func() { closed <- listener.EndInternalUI(ctx, second) }()
for listener.InternalUISession() != 0 {
if ctx.Err() != nil {
t.Fatal("held-key close did not revoke UI")
}
time.Sleep(time.Millisecond)
}
select {
case <-closed:
t.Fatal("close acknowledged before physical release")
default:
}
if err := exec.CommandContext(ctx, "xdotool", "keyup", "Return").Run(); err != nil {
t.Fatal(err)
}
if err := <-closed; err != nil {
t.Fatal(err)
}
if err := listener.EndInternalUI(ctx, second); err != nil {
t.Fatal(err)
}
third, err := listener.BeginInternalUI(ctx)
if err != nil {
t.Fatal(err)
}
if err := exec.CommandContext(ctx, "xdotool", "keydown", "Shift_L", "F12").Run(); err != nil {
t.Fatal(err)
}
want(input.InternalUIKey)
listener.SetSecureInputActive(true)
for listener.InternalUISession() != 0 || listener.InternalUIEchoActive() {
if ctx.Err() != nil {
t.Fatal("lock did not revoke")
}
time.Sleep(time.Millisecond)
}
if err := exec.CommandContext(ctx, "xdotool", "keyup", "F12", "Shift_L").Run(); err != nil {
t.Fatal(err)
}
if _, err := listener.BeginInternalUI(ctx); err == nil {
t.Fatal("opened during lock")
}
listener.SetSecureInputActive(false)
if err := listener.EndInternalUI(ctx, third); err != nil {
t.Fatal(err)
}
if background := os.Getenv("CTHULHU_UI_BACKGROUND"); background != "" {
fourth, err := listener.BeginInternalUI(ctx)
if err != nil {
t.Fatal(err)
}
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", background).Run(); err != nil {
t.Fatal(err)
}
dismissed := want(input.InternalUIDismiss)
if dismissed.UISession != fourth || listener.InternalUISession() != 0 {
t.Fatal("focus change did not revoke interaction")
}
if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil {
t.Fatal(err)
}
}
key("Insert+t")
want(input.PresentTime)
key("y")
_ = probe.Process.Kill()
_ = probe.Wait()
data, err := os.ReadFile(log.Name())
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"keysym 0xff54, Down", "keysym 0xff52, Up", "keysym 0xff0d, Return", "keysym 0xff1b, Escape", "keysym 0x7a, z", "keysym 0x41, A", "keysym 0x21, exclam", "keysym 0xe9, eacute"} {
if strings.Contains(string(data), name) {
t.Fatalf("UI key leaked to application: %s", name)
}
}
if !strings.Contains(string(data), "keysym 0x79, y") {
t.Fatal("ordinary typing did not resume")
}
}