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") }