Files
mesh-catalog/modules/i3/cmd/i3-tools/i3_test.go
T
jochen 34829e39fe i3: the window manager holds node-display-session; its config improved and a checked reload watcher (hq ADR 0208)
It requires x11-display and contributes exec i3 to xinitrc's last slot, and
XDG_CURRENT_DESKTOP/XDG_SESSION_DESKTOP to the environment. It owns
~/.config/i3/config, ending with the config.d include where other modules drop
their files, and /etc/lemurs/wms/i3, which runs the session's start.

Over today's identical config it drops the dead lxpolkit, the D-Bus-activated
portal, the xrdb merge xorg now does, and the execs that XDG autostart already
started. It sets JetBrainsMono Nerd Font, runs i3-sensible-terminal, and declares
dex, which the desktop lacked.

The tools speak i3's IPC: the seat's reload, workspaces and windows, and focus,
move, layout save/restore, exec, kill, bindings, config check, marks and the
scratchpad. A watcher in the bundle (ADR 0198) replaces the predecessor's inotify
script and user unit. It reloads only a configuration i3 -C accepts, and at its
start whatever i3 has not loaded.
2026-10-04 13:21:47 +02:00

372 lines
13 KiB
Go

package main
import (
"context"
"encoding/binary"
"encoding/json"
"io"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"i3/internal/desktop"
)
// fakeI3 answers i3's IPC on a socket of its own: a fixed tree, workspaces and config, and every
// RUN_COMMAND recorded and answered with success unless it contains "nonsense".
type fakeI3 struct {
path string
mu sync.Mutex
commands []string
}
const tree = `{"id":1,"type":"root","name":"root","nodes":[
{"id":2,"type":"output","name":"__i3","nodes":[{"id":3,"type":"con","name":"content","nodes":[
{"id":4,"type":"workspace","name":"__i3_scratch","nodes":[],"floating_nodes":[
{"id":5,"type":"floating_con","floating":"user_on","nodes":[{"id":6,"type":"con","name":"notes","window":101,"window_properties":{"class":"XTerm","instance":"notes"},"scratchpad_state":"fresh"}]}]}]}]},
{"id":10,"type":"output","name":"eDP-1","nodes":[
{"id":11,"type":"dockarea","name":"topdock","nodes":[{"id":12,"type":"con","name":"i3bar for output eDP-1","window":200,"window_properties":{"class":"i3bar"}}]},
{"id":13,"type":"con","name":"content","nodes":[
{"id":20,"type":"workspace","name":"1","layout":"splith","nodes":[
{"id":21,"type":"con","name":"Mail - Thunderbird","layout":"splith","percent":0.6,"border":"pixel","current_border_width":1,"floating":"auto_off","window":301,"window_properties":{"class":"thunderbird","instance":"Mail","window_role":"3pane"},"marks":["mail"]},
{"id":22,"type":"con","name":null,"layout":"splitv","percent":0.4,"border":"pixel","floating":"auto_off","nodes":[
{"id":23,"type":"con","name":"op: ~ (main)","window":302,"focused":true,"window_properties":{"class":"XTerm","instance":"xterm"}},
{"id":24,"type":"con","name":"a.b (c)","window":303,"window_properties":{"class":"Foo.Bar","instance":"x"}}]}]}]}]}]}`
func startFake(t *testing.T) *fakeI3 {
dir, err := os.MkdirTemp("", "i3ipc")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
f := &fakeI3{path: filepath.Join(dir, "ipc")}
l, err := net.Listen("unix", f.path)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
go func() {
for {
c, err := l.Accept()
if err != nil {
return
}
go f.serve(c)
}
}()
return f
}
func (f *fakeI3) serve(c net.Conn) {
defer c.Close()
head := make([]byte, 14)
if _, err := io.ReadFull(c, head); err != nil {
return
}
n := binary.LittleEndian.Uint32(head[6:])
kind := binary.LittleEndian.Uint32(head[10:])
body := make([]byte, n)
io.ReadFull(c, body)
var reply string
switch kind {
case RunCommand:
f.mu.Lock()
f.commands = append(f.commands, string(body))
f.mu.Unlock()
if strings.Contains(string(body), "nonsense") {
reply = `[{"success":false,"error":"Expected one of these tokens"}]`
} else {
reply = `[{"success":true}]`
}
case GetWorkspaces:
reply = `[{"num":1,"name":"1","output":"eDP-1","visible":true,"focused":true,"urgent":false}]`
case GetTree:
reply = tree
case GetVersion:
reply = `{"human_readable":"4.25.1","loaded_config_file_name":"/c/config","included_config_file_names":["/c/config.d/50-x.conf"]}`
case GetConfig:
reply = `{"config":"x","included_configs":[{"path":"/c/config","variable_replaced_contents":"bindsym Mod4+Return exec i3-sensible-terminal\nmode \"resize\" {\n bindsym h resize shrink width 10 px\n bindsym Escape mode \"default\"\n}\nbindsym --release Control+Shift+x exec shot\n"},{"path":"/c/config.d/50-x.conf","variable_replaced_contents":"bindcode 133 exec rofi\n"}]}`
}
out := make([]byte, 14+len(reply))
copy(out, "i3-ipc")
binary.LittleEndian.PutUint32(out[6:], uint32(len(reply)))
binary.LittleEndian.PutUint32(out[10:], kind)
copy(out[14:], reply)
c.Write(out)
}
func (f *fakeI3) ran() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.commands...)
}
func withFake(t *testing.T) (*i3, *fakeI3) {
f := startFake(t)
dir := t.TempDir()
return &i3{
d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
socket: func() (string, error) { return f.path, nil },
configDir: dir, layouts: filepath.Join(dir, "layouts"),
}, f
}
func run(t *testing.T, x *i3, tool string, args map[string]any) (any, error) {
for _, tl := range tools(x) {
if tl.Name == tool {
if args == nil {
args = map[string]any{}
}
return tl.Run(args)
}
}
t.Fatalf("no tool %s", tool)
return nil, nil
}
func asJSON(v any) string {
b, _ := json.Marshal(v)
return string(b)
}
func TestWindowsAreTheTreesWindowsWithTheirWorkspaceAndNotTheBars(t *testing.T) {
var root Node
if err := json.Unmarshal([]byte(tree), &root); err != nil {
t.Fatal(err)
}
w := Windows(root)
if len(w) != 4 {
t.Fatalf("%d windows: %+v", len(w), w)
}
if w[0].Title != "notes" || !w[0].Scratchpad || !w[0].Floating || w[0].Workspace != "__i3_scratch" {
t.Fatalf("the scratchpad's window: %+v", w[0])
}
if w[1].Class != "thunderbird" || w[1].Workspace != "1" || w[1].Output != "eDP-1" || w[1].Window != "0x12d" || w[1].Marks[0] != "mail" {
t.Fatalf("%+v", w[1])
}
if !w[2].Focused {
t.Fatalf("%+v", w[2])
}
}
func TestTheSeatsVerbsAnswerFromI3(t *testing.T) {
x, _ := withFake(t)
got, err := run(t, x, "node-display-session.windows", map[string]any{"workspace": "1"})
if err != nil || len(got.(map[string]any)["windows"].([]Window)) != 3 {
t.Fatalf("%v %v", asJSON(got), err)
}
got, err = run(t, x, "node-display-session.workspaces", nil)
if err != nil || !strings.Contains(asJSON(got), `"focused":true`) {
t.Fatalf("%v %v", got, err)
}
}
func TestReloadIsRefusedWhenTheConfigurationHasErrors(t *testing.T) {
x, f := withFake(t)
x.d.Run = func(_ context.Context, _ []string, _ []byte, name string, args ...string) desktop.Result {
return desktop.Result{Code: 1, Stderr: "10/04/2026 - ERROR: CONFIG: Expected one of these tokens\n" +
"10/04/2026 - ERROR: CONFIG: (in file /c/config.d/99-x.conf)\n10/04/2026 - ERROR: CONFIG: Line 3: foo bar\n" +
"10/04/2026 - ERROR: CONFIG: ^^^^^^^\n"}
}
got, err := run(t, x, "node-display-session.reload", nil)
if err != nil || got.(map[string]any)["reloaded"] != false || len(f.ran()) != 0 {
t.Fatalf("%v %v %v", asJSON(got), err, f.ran())
}
e := got.(map[string]any)["errors"].([]ConfigError)
if len(e) != 1 || e[0].File != "/c/config.d/99-x.conf" || e[0].Line != 3 || !strings.Contains(e[0].Text, "foo bar") {
t.Fatalf("%+v", e)
}
if _, err := run(t, x, "node-display-session.reload", map[string]any{"force": true}); err != nil || f.ran()[0] != "reload" {
t.Fatalf("forced: %v %v", err, f.ran())
}
}
func TestCommandsQuoteWhatTheCallerGave(t *testing.T) {
x, f := withFake(t)
steps := []struct {
tool string
args map[string]any
want string
}{
{"i3_focus", map[string]any{"class": `Fire"fox`}, `[class="Fire\"fox"] focus`},
{"i3_focus", map[string]any{"workspace": "2: mail"}, `workspace "2: mail"`},
{"i3_move", map[string]any{"con_id": float64(21), "to_workspace": "3"}, `[con_id=21] move container to workspace "3"`},
{"i3_move", map[string]any{"to_output": "right"}, `move container to output right`},
{"i3_move", map[string]any{"workspace_to_output": "1", "to_output": "DP-2"}, `[workspace="^1$"] move workspace to output "DP-2"`},
{"i3_exec", map[string]any{"command": "xterm -e 'a; b'", "workspace": "4"}, `workspace "4"; exec --no-startup-id "xterm -e 'a; b'"`},
{"i3_kill", map[string]any{"window": "0x12d"}, `[id=301] kill`},
{"i3_kill", map[string]any{"focused": true, "force": true}, `kill client`},
{"i3_scratchpad", map[string]any{"action": "show", "mark": "notes"}, `[con_mark="notes"] scratchpad show`},
{"i3_scratchpad", map[string]any{"action": "move"}, `move scratchpad`},
}
for i, s := range steps {
if _, err := run(t, x, s.tool, s.args); err != nil {
t.Fatalf("%s: %v", s.tool, err)
}
if got := f.ran()[i]; got != s.want {
t.Errorf("%s: %q, want %q", s.tool, got, s.want)
}
}
for _, refused := range []struct {
tool string
args map[string]any
}{
{"i3_kill", nil}, {"i3_focus", nil}, {"i3_move", map[string]any{"class": "x"}},
{"i3_focus", map[string]any{"window": "301"}}, {"i3_layout_save", map[string]any{"name": "../x"}},
} {
if _, err := run(t, x, refused.tool, refused.args); err == nil {
t.Errorf("%s %v was accepted", refused.tool, refused.args)
}
}
if _, err := run(t, x, "i3_exec", map[string]any{"command": "nonsense"}); err == nil {
t.Fatal("i3's refusal is the tool's")
}
}
func TestALayoutIsSavedAsPlaceholdersAndLaidBackOnItsWorkspace(t *testing.T) {
x, f := withFake(t)
got, err := run(t, x, "i3_layout_save", map[string]any{"name": "mail"})
if err != nil {
t.Fatal(err)
}
if m := got.(map[string]any); m["workspace"] != "1" || m["windows"] != 3 {
t.Fatalf("the focused workspace, its three windows: %v", m)
}
b, _ := os.ReadFile(filepath.Join(x.layouts, "mail.json"))
text := string(b)
if SavedWorkspace(text) != "1" || !strings.Contains(text, `"class": "^thunderbird$"`) || !strings.Contains(text, `"class": "^Foo\\.Bar$"`) ||
!strings.Contains(text, `"window_role": "^3pane$"`) || !strings.Contains(text, `"layout": "splitv"`) || strings.Contains(text, `"window": `) {
t.Fatalf("%s", text)
}
got, err = run(t, x, "i3_layout_restore", map[string]any{"name": "mail"})
if err != nil {
t.Fatal(err)
}
if last := f.ran()[len(f.ran())-1]; last != `workspace "1"; append_layout "`+filepath.Join(x.layouts, "mail.json")+`"` {
t.Fatalf("%q", last)
}
got, _ = run(t, x, "i3_layout_restore", map[string]any{"name": "list"})
if !strings.Contains(asJSON(got), `"name":"mail"`) {
t.Fatalf("%v", asJSON(got))
}
}
func TestBindingsAreReadByModeWithTheirFiles(t *testing.T) {
x, _ := withFake(t)
got, err := run(t, x, "i3_bindings", nil)
if err != nil {
t.Fatal(err)
}
b := got.(map[string]any)["bindings"].([]Binding)
if len(b) != 5 {
t.Fatalf("%+v", b)
}
if b[1].Mode != "resize" || b[1].Keys != "h" || b[3].Mode != "default" || !b[3].Release || b[3].Keys != "Control+Shift+x" {
t.Fatalf("%+v", b)
}
if b[4].Kind != "bindcode" || b[4].File != "/c/config.d/50-x.conf" {
t.Fatalf("%+v", b[4])
}
got, _ = run(t, x, "i3_bindings", map[string]any{"match": "rofi"})
if len(got.(map[string]any)["bindings"].([]Binding)) != 1 {
t.Fatal("match")
}
}
func TestWithNoI3RunningTheToolsSaySo(t *testing.T) {
x := &i3{d: desktop.Desk{Run: func(context.Context, []string, []byte, string, ...string) desktop.Result { return desktop.Result{} }},
socket: func() (string, error) { return FindSocket(t.TempDir(), "") }, configDir: t.TempDir()}
for _, tool := range []string{"node-display-session.windows", "node-display-session.workspaces", "i3_marks", "i3_bindings"} {
if _, err := run(t, x, tool, nil); !desktop.IsNoSession(err) {
t.Errorf("%s: %v", tool, err)
}
}
got, err := run(t, x, "i3_config_check", nil)
if err != nil || got.(map[string]any)["valid"] != true || got.(map[string]any)["running"] != nil {
t.Fatalf("the check needs no running i3: %v %v", got, err)
}
}
func TestTheWatcherReloadsOnceAfterAChangeAndNeverIntoErrors(t *testing.T) {
dir := t.TempDir()
os.WriteFile(filepath.Join(dir, "config"), []byte("a"), 0o644)
var mu sync.Mutex
reloads, broken := 0, false
w := &Watcher{Dir: dir, Every: 20 * time.Millisecond,
Check: func() ([]ConfigError, error) {
mu.Lock()
defer mu.Unlock()
if broken {
return []ConfigError{{Text: "bad"}}, nil
}
return nil, nil
},
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
}
stop := make(chan struct{})
defer close(stop)
go w.Run(stop)
time.Sleep(60 * time.Millisecond)
os.MkdirAll(filepath.Join(dir, "config.d"), 0o755)
os.WriteFile(filepath.Join(dir, "config.d", "50-x.conf"), []byte("b"), 0o644)
os.WriteFile(filepath.Join(dir, "config"), []byte("aa"), 0o644)
waitFor(t, func() bool { mu.Lock(); defer mu.Unlock(); return reloads == 1 })
time.Sleep(100 * time.Millisecond)
mu.Lock()
if reloads != 1 {
t.Fatalf("one reload for one change of two files: %d", reloads)
}
broken = true
mu.Unlock()
os.WriteFile(filepath.Join(dir, "config"), []byte("aaa"), 0o644)
waitFor(t, func() bool { return len(w.Status().Refused) == 1 })
if s := w.Status(); s.Reloads != 1 || s.Files != 2 || !strings.Contains(s.LastProblem, "not reloaded") {
t.Fatalf("%+v", s)
}
}
func TestAtItsStartTheWatcherReloadsWhatTheRunningI3HasNotLoaded(t *testing.T) {
dir := t.TempDir()
cfg := filepath.Join(dir, "config")
os.WriteFile(cfg, []byte("new"), 0o644)
for _, c := range []struct {
loaded map[string]string
want int
}{
{map[string]string{cfg: "old"}, 1},
{map[string]string{cfg: "new"}, 0},
{map[string]string{cfg: "new", filepath.Join(dir, "config.d", "gone.conf"): "x"}, 1},
} {
var mu sync.Mutex
reloads := 0
w := &Watcher{Dir: dir, Every: 10 * time.Millisecond,
Check: func() ([]ConfigError, error) { return nil, nil },
Reload: func() error { mu.Lock(); reloads++; mu.Unlock(); return nil },
Loaded: func() (map[string]string, error) { return c.loaded, nil },
}
stop := make(chan struct{})
go w.Run(stop)
time.Sleep(80 * time.Millisecond)
close(stop)
mu.Lock()
if reloads != c.want {
t.Errorf("loaded %v: %d reloads, want %d", c.loaded, reloads, c.want)
}
mu.Unlock()
}
}
func waitFor(t *testing.T, ok func() bool) {
for i := 0; i < 200; i++ {
if ok() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("timed out")
}