Files
cthulhu/internal/i3ipc/i3_native_test.go

326 lines
9.8 KiB
Go

// Copyright (c) 2026 Storm Dragon
// Authors: Storm Dragon, Angus Kola
// SPDX-License-Identifier: GPL-3.0-or-later
package i3ipc
import (
"context"
"encoding/json"
"fmt"
"net"
"os"
"os/exec"
"strconv"
"strings"
"testing"
"time"
"git.stormux.org/storm/cthulhu/internal/input"
"git.stormux.org/storm/cthulhu/internal/platform/xorg"
)
func TestNativeI3EmptyWorkspaceFocus(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3") != "1" {
t.Skip("requires private i3 harness")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
command := func(text string) {
t.Helper()
if out, err := exec.CommandContext(ctx, "i3-msg", "-s", xorg.I3Socket(), text).CombinedOutput(); err != nil {
t.Fatalf("i3 command: %v %s", err, out)
}
}
command(`workspace "handoff terminal"`)
terminal := exec.CommandContext(ctx, "xterm", "-T", "handoff fixture", "-e", "sleep", "30")
if err := terminal.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = terminal.Process.Kill(); _ = terminal.Wait() }()
listener := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
if err := listener.Start(ctx); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
waitFor := func(check func() bool, message string) {
t.Helper()
deadline := time.Now().Add(time.Second)
for !check() {
if time.Now().After(deadline) {
t.Fatal(message)
}
time.Sleep(time.Millisecond)
}
}
wantFocus := func(xterm bool) {
t.Helper()
timer := time.NewTimer(2 * time.Second)
defer timer.Stop()
for {
select {
case state := <-listener.FocusChanges():
if state.Known && state.XTerm == xterm {
return
}
case err := <-listener.Failures():
t.Fatal(err)
case <-timer.C:
out, _ := exec.CommandContext(ctx, "xprop", "-root", "_NET_ACTIVE_WINDOW", "_NET_SUPPORTING_WM_CHECK").CombinedOutput()
focus, _ := exec.CommandContext(ctx, "xdotool", "getwindowfocus").CombinedOutput()
t.Fatalf("missing focus state XTerm=%t; %s input focus: %s", xterm, out, focus)
}
}
}
wantFocus(true)
for _, floating := range []string{"disable", "enable"} {
command(`[title="handoff fixture"] floating ` + floating)
command(`workspace "handoff empty"`)
wantFocus(false)
waitFor(listener.CommandGrabsEnabled, "empty workspace did not restore grabs")
// A real shortcut proves that the native XTerm veto was cleared,
// rather than merely reporting the new state to the app.
if out, err := exec.CommandContext(ctx, "xdotool", "key", "Insert+h").CombinedOutput(); err != nil {
t.Fatalf("shortcut: %v %s", err, out)
}
select {
case event := <-listener.Commands():
if event.Command != input.ToggleLearnMode {
t.Fatalf("empty-workspace shortcut = %+v", event)
}
case <-ctx.Done():
t.Fatal("empty-workspace shortcut did not dispatch")
}
// Returning to XTerm must end learn mode and release its keyboard.
command(`workspace "handoff terminal"`)
wantFocus(true)
waitFor(func() bool { return !listener.CommandGrabsEnabled() && !listener.LearnModeActive() },
"returning to XTerm kept command or learn-mode grabs")
}
// Losing the last client without switching workspaces uses the same path.
command(`[title="handoff fixture"] move container to workspace "handoff moved"`)
wantFocus(false)
waitFor(listener.CommandGrabsEnabled, "moving the last client did not restore grabs")
}
func TestNativeI3Events(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3") != "1" {
t.Skip("requires private i3 harness")
}
path := xorg.I3Socket()
if path == "" {
t.Fatal("active i3/socket not detected")
}
// A stale environment hint must not override the verified session property.
t.Setenv("I3SOCK", "/nonexistent/stale-socket")
if xorg.I3Socket() != path {
t.Fatal("stale environment overrode discovery")
}
query := func(kind uint32, payload string, result any) {
t.Helper()
c, err := net.DialTimeout("unix", path, time.Second)
if err != nil {
t.Fatal(err)
}
defer c.Close()
c.SetDeadline(time.Now().Add(2 * time.Second))
if err = writeMessage(c, kind, []byte(payload)); err != nil {
t.Fatal(err)
}
m, err := readMessage(c)
if err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(m.body, result); err != nil {
t.Fatal(err)
}
}
command := func(text string) {
t.Helper()
var replies []struct{ Success bool }
query(0, text, &replies)
for _, r := range replies {
if !r.Success {
t.Fatalf("i3 rejected %q", text)
}
}
}
getTree := func() node { var root node; query(tree, "", &root); return root }
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events := make(chan Event, 128)
done := make(chan error, 1)
go func() { done <- session(ctx, path, func(e Event) { events <- e }) }()
// Synchronize baseline completion through an actual reported mode transition.
timer := time.NewTicker(30 * time.Millisecond)
defer timer.Stop()
ready := false
deadline := time.After(4 * time.Second)
for !ready {
select {
case <-timer.C:
command(`mode "personal"`)
case e := <-events:
ready = e.Kind == Mode && e.Name == "personal"
case err := <-done:
t.Fatal(err)
case <-deadline:
t.Fatal("backend did not become ready")
}
}
expect := func(kind Kind, name string) Event {
t.Helper()
for {
select {
case e := <-events:
if e.Kind == kind && (name == "" || e.Name == name) {
return e
}
t.Logf("additional event: %+v", e)
case err := <-done:
t.Fatalf("backend exited: %v", err)
case <-time.After(3 * time.Second):
t.Fatalf("missing event %d %q", kind, name)
}
}
}
quiet := func() {
t.Helper()
select {
case e := <-events:
t.Fatalf("unexpected announcement: %+v", e)
case <-time.After(100 * time.Millisecond):
}
}
for _, mode := range []string{"ratpoison", "panel", "default"} {
command(`mode "` + mode + `"`)
expect(Mode, mode)
}
command(`mode "<b>marked &amp; mode</b>"`)
if e := expect(Mode, ""); !e.Markup || e.Name != "<b>marked &amp; mode</b>" {
t.Fatal(e)
}
command(`mode "default"`)
expect(Mode, "default")
command(`workspace "empty arbitrary & name"`)
expect(Workspace, "empty arbitrary & name")
command(`rename workspace to "renamed empty"`)
expect(Workspace, "renamed empty")
terminal := exec.Command("xterm", "-T", "i3 fixture", "-e", "sleep", "60")
if err := terminal.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = terminal.Process.Kill(); _ = terminal.Wait() }()
var window node
var find func(node)
find = func(n node) {
if n.Name == "i3 fixture" && n.Window != 0 {
window = n
}
for _, list := range [][]node{n.Nodes, n.FloatingNodes} {
for _, child := range list {
find(child)
}
}
}
until := time.Now().Add(4 * time.Second)
for window.ID == 0 && time.Now().Before(until) {
find(getTree())
if window.ID == 0 {
time.Sleep(20 * time.Millisecond)
}
}
if window.ID == 0 {
t.Fatal("fixture window missing")
}
// A marker event follows window creation/focus, proving those are silent.
command(`mode "panel"`)
expect(Mode, "panel")
quiet()
target := fmt.Sprintf("[con_id=%d] ", window.ID)
for _, tc := range []struct {
command string
kind Kind
enabled bool
}{
{"fullscreen enable", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
{"fullscreen enable global", Fullscreen, true}, {"fullscreen disable", Fullscreen, false},
{"floating enable", Floating, true}, {"floating disable", Floating, false},
} {
command(target + tc.command)
e := expect(tc.kind, "")
if e.Enabled != tc.enabled {
t.Fatal(e)
}
}
command(target + `mark "a & mark"`)
expect(MarkAdded, "a & mark")
command(target + `unmark "a & mark"`)
expect(MarkRemoved, "a & mark")
command(target + `floating enable`)
expect(Floating, "")
command(target + `move position center`)
quiet()
command(target + `floating disable`)
expect(Floating, "")
command(target + `move container to workspace "destination"`)
if e := expect(WorkspaceMove, ""); e.Workspace != "destination" {
t.Fatal(e)
}
// It is now a background window; urgency must identify both contexts once.
out, err := exec.Command("xdotool", "set_window", "--urgency", "1", strconv.FormatUint(window.Window, 10)).CombinedOutput()
if err != nil {
t.Fatalf("urgency: %v %s", err, out)
}
if e := expect(Urgent, ""); e.Window != "i3 fixture" || e.Workspace != "destination" {
t.Fatal(e)
}
quiet()
command(target + `fullscreen enable`)
if e := expect(Fullscreen, ""); e.Focused || e.Window != "i3 fixture" {
t.Fatal(e)
}
command(target + `fullscreen disable`)
expect(Fullscreen, "")
command(target + `move scratchpad`)
quiet()
command(target + `scratchpad show`)
quiet()
cancel()
select {
case err := <-done:
if err != nil && !strings.Contains(err.Error(), "closed") && ctx.Err() == nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("backend did not stop")
}
}
func TestNativeI3Unavailable(t *testing.T) {
if os.Getenv("CTHULHU_NATIVE_I3_UNAVAILABLE") != "1" {
t.Skip("requires empty private X display")
}
t.Setenv("I3SOCK", "/tmp/stale-i3")
if path := xorg.I3Socket(); path != "" {
t.Fatalf("activated without i3: %q", path)
}
// No active client on an unverified WM must remain unknown. The i3
// exception must not relax the existing focus policy on other desktops.
if out, err := exec.Command("xprop", "-root", "-f", "_NET_ACTIVE_WINDOW", "32x", "-set", "_NET_ACTIVE_WINDOW", "0x0").CombinedOutput(); err != nil {
t.Fatalf("set empty foreground: %v %s", err, out)
}
defer exec.Command("xprop", "-root", "-remove", "_NET_ACTIVE_WINDOW").Run()
listener := xorg.NewCommands([]string{"Insert"}, input.DesktopKeyboardLayout)
if err := listener.Start(context.Background()); err != nil {
t.Fatal(err)
}
defer listener.Stop(context.Background())
select {
case state := <-listener.FocusChanges():
t.Fatalf("unverified empty foreground became known: %+v", state)
case err := <-listener.Failures():
t.Fatal(err)
case <-time.After(200 * time.Millisecond):
}
}