// 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 "marked & mode"`) if e := expect(Mode, ""); !e.Markup || e.Name != "marked & mode" { 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): } }