package xorg import ( "context" "git.stormux.org/storm/cthulhu/internal/input" "os" "os/exec" "strings" "testing" "time" ) func TestNativeTentacleBindingsUpdateAndRelease(t *testing.T) { if os.Getenv("CTHULHU_NATIVE_X11") != "1" { t.Skip("requires isolated X11 display") } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Own a foreground client; browse-only grabs deliberately replay when // no client owns focus or the foreground window has changed. probe := exec.CommandContext(ctx, "xev", "-name", "Cthulhu Tentacle Bindings", "-event", "keyboard") if err := probe.Start(); err != nil { t.Fatal(err) } defer func() { _ = probe.Process.Kill(); _ = probe.Wait() }() window := "" for window == "" { output, err := exec.CommandContext(ctx, "xdotool", "search", "--onlyvisible", "--name", "^Cthulhu Tentacle Bindings$").Output() if err == nil { window = strings.TrimSpace(string(output)) } select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(time.Millisecond): } } if err := exec.CommandContext(ctx, "xdotool", "windowfocus", window).Run(); err != nil { t.Fatal(err) } listener := NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout) custom := []input.BindingOverride{ {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/global"), Keys: []string{"Cthulhu+Ctrl+Alt+y"}}, {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/global"), Keys: []string{"F9"}}, {Layout: input.DesktopKeyboardLayout, Scope: "browse", Command: input.Command("tentacle/test/browse"), Keys: []string{"F6"}}, {Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Command("tentacle/test/wormhole"), Keys: []string{"Cthulhu+Ctrl+Shift+W"}}, } if err := listener.SetTentacleBindings(ctx, custom); err != nil { t.Fatal(err) } if err := listener.Start(ctx); err != nil { t.Fatal(err) } defer listener.Stop(context.Background()) key := func(args ...string) { t.Helper() if out, err := exec.CommandContext(ctx, "xdotool", args...).CombinedOutput(); err != nil { t.Fatalf("keys: %v %s", err, out) } } command := func(want input.Command) { t.Helper() select { case got := <-listener.Commands(): if got.Command != want { t.Fatalf("command %s want %s", got.Command, want) } case err := <-listener.Failures(): t.Fatal(err) case <-ctx.Done(): t.Fatal(ctx.Err()) } } key("keydown", "Insert", "keydown", "Control_L", "keydown", "Alt_L", "keydown", "y") command(input.Command("tentacle/test/global")) // Queue a new configuration while Y is held: it must wait for releases. applied := make(chan error, 1) go func() { applied <- listener.SetTentacleBindings(ctx, nil) }() select { case err := <-applied: t.Fatalf("update completed while keys were held: %v", err) case <-time.After(40 * time.Millisecond): } key("keyup", "Insert", "keyup", "Control_L", "keyup", "Alt_L", "keyup", "y") if err := <-applied; err != nil { t.Fatal(err) } key("keydown", "Insert", "key", "space", "keyup", "Insert") command(input.Preferences) if err := listener.SetTentacleBindings(ctx, custom); err != nil { t.Fatal(err) } key("key", "F9") command(input.Command("tentacle/test/global")) key("keydown", "Insert", "keydown", "Control_L", "keydown", "Shift_L", "key", "w", "keyup", "Shift_L", "keyup", "Control_L", "keyup", "Insert") command(input.Command("tentacle/test/wormhole")) // All modifiers released: an ordinary W must reach the focused application. key("key", "w") key("key", "F9") command(input.Command("tentacle/test/global")) if err := listener.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.DesktopKeyboardLayout, Scope: "global", Command: input.Preferences, Keys: []string{"F9"}}}); err != nil { t.Fatal(err) } key("key", "F9") command(input.Preferences) if err := listener.SetKeyBindings(ctx, nil); err != nil { t.Fatal(err) } key("key", "F9") command(input.Command("tentacle/test/global")) key("keydown", "Insert", "key", "h", "keyup", "Insert") command(input.ToggleLearnMode) key("key", "F9") command(input.LearnKey) key("key", "Escape") command(input.ToggleLearnMode) listener.SetBrowseNavigation(true) for !listener.BrowseNavigationEnabled() { select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(time.Millisecond): } } key("key", "F6") command(input.Command("tentacle/test/browse")) listener.SetBrowseNavigation(false) for listener.browseApplied.Load() { select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(time.Millisecond): } } key("key", "F6") key("key", "F9") command(input.Command("tentacle/test/global")) // F6 outside browse mode must not dispatch. competitor := NewCommands([]string{"KP_Insert"}, input.LaptopKeyboardLayout) if err := competitor.SetKeyBindings(ctx, []input.BindingOverride{{Layout: input.LaptopKeyboardLayout, Scope: "global", Command: input.PresentDate, Keys: []string{"F10"}}}); err != nil { t.Fatal(err) } if err := competitor.Start(ctx); err != nil { t.Fatal(err) } collision := input.CloneBindingOverrides(custom) collision[0].Keys = []string{"F10"} if err := listener.SetTentacleBindings(ctx, collision); err == nil { t.Fatal("accepted conflicting native grab") } key("key", "F9") command(input.Command("tentacle/test/global")) // Native rollback retained the old shortcut. browseCollision := input.CloneBindingOverrides(custom) browseCollision[2].Keys = []string{"F10"} if err := listener.SetTentacleBindings(ctx, browseCollision); err == nil { t.Fatal("accepted conflicting prospective browse grab") } key("key", "F9") command(input.Command("tentacle/test/global")) if err := competitor.Stop(ctx); err != nil { t.Fatal(err) } unavailable := input.CloneBindingOverrides(custom) unavailable[0].Keys = []string{"Cthulhu+F35"} if err := listener.SetTentacleBindings(ctx, unavailable); err == nil { t.Fatal("accepted unavailable key") } key("key", "F9") command(input.Command("tentacle/test/global")) // Rejected update preserved the previous router. listener.SetSecureInputActive(true) for listener.CommandGrabsEnabled() { select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(time.Millisecond): } } key("key", "F9") listener.SetSecureInputActive(false) for !listener.CommandGrabsEnabled() { select { case <-ctx.Done(): t.Fatal(ctx.Err()) case <-time.After(time.Millisecond): } } key("key", "F9") command(input.Command("tentacle/test/global")) if err := listener.SetTentacleBindings(ctx, nil); err != nil { t.Fatal(err) } if len(listener.TentacleBindings()) != 0 { t.Fatal("removed bindings still published") } key("key", "F9") key("keydown", "Insert", "key", "space", "keyup", "Insert") command(input.Preferences) }