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.
372 lines
13 KiB
Go
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")
|
|
}
|