1package statefile
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12 "testing/synctest"
13 "time"
14
15 "github.com/bnema/neferwl/internal/adapters/workspaceid"
16 "github.com/bnema/neferwl/internal/ports"
17 "github.com/bnema/zerowrap"
18)
19
20var sample = State{
21 Output: "DP-2",
22 Outputs: []Output{{Name: "HDMI-A-1", Active: 1, Count: 1, WorkspaceID: "p-1"}, {Name: "DP-2", Active: 2, Count: 3, Workspace: "web", WorkspaceID: "name:web"}},
23 Windows: []Window{{ID: 1, AppID: "foot", PID: 100, Output: "HDMI-A-1", Workspace: 1, WorkspaceID: "p-1", Visible: true}, {ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 2, WorkspaceID: "name:web", Visible: true}},
24}
25
26// The file holds the latest state while the session runs; a stale file from
27// a crashed session goes at start, and the file is removed at exit.
28func TestRunWritesAndRemoves(t *testing.T) {
29 path, err := Path(t.TempDir(), "wayland-9")
30 if err != nil {
31 t.Fatal(err)
32 }
33 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
34 t.Fatal(err)
35 }
36 if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil {
37 t.Fatal(err)
38 }
39 states := make(chan ports.State, 1)
40 ctx, cancel := context.WithCancel(context.Background())
41 done := make(chan error, 1)
42 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
43 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
44 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
45 want := State{Output: "DP-2", Outputs: []Output{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: "0a1b2c3d-9"}}, Window: &Window{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}, Windows: []Window{{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: "0a1b2c3d-9", Visible: true}}, Workspaces: []Workspace{}}
46 deadline := time.Now().Add(2 * time.Second)
47 for {
48 if got, err := Read(path); err == nil && got.Output != "stale" {
49 if !reflect.DeepEqual(got, want) {
50 t.Fatalf("%+v", got)
51 }
52 break
53 }
54 if time.Now().After(deadline) {
55 t.Fatal("no state file")
56 }
57 time.Sleep(5 * time.Millisecond)
58 }
59 cancel()
60 if err := <-done; err != nil {
61 t.Fatal(err)
62 }
63 if _, err := os.Stat(path); !os.IsNotExist(err) {
64 t.Fatal("state file left behind", err)
65 }
66}
67
68// Stash and cell fields reach the file with their script-facing names.
69func TestWindowScriptFields(t *testing.T) {
70 got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Column: 5, Row: 6, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
71 if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || got.Column != 5 || got.Row != 6 || !got.Hidden {
72 t.Fatalf("%+v", got)
73 }
74 data, err := json.Marshal(got)
75 if err != nil {
76 t.Fatal(err)
77 }
78 for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"column":5`, `"row":6`, `"hidden":true`} {
79 if !strings.Contains(string(data), key) {
80 t.Fatalf("%s missing %s", data, key)
81 }
82 }
83}
84
85func TestPath(t *testing.T) {
86 if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" {
87 t.Fatal(p, err)
88 }
89 for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} {
90 if _, err := Path(c[0], c[1]); err == nil {
91 t.Fatal(c)
92 }
93 }
94}
95
96// A shell finds its window through its ancestors.
97func TestOutputOfWalksParents(t *testing.T) {
98 tree := newMockprocTree(t)
99 // shell 300 → login 250 → terminal 200 (window on DP-2)
100 tree.EXPECT().Parent(300).Return(250, true)
101 tree.EXPECT().Parent(250).Return(200, true)
102 o, err := outputOf(sample, 300, tree)
103 if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 {
104 t.Fatal(o, err)
105 }
106 // The window's own process needs no walk.
107 if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" {
108 t.Fatal(o, err)
109 }
110 // No window up to init.
111 orphan := newMockprocTree(t)
112 orphan.EXPECT().Parent(400).Return(1, true)
113 orphan.EXPECT().Parent(1).Return(0, false)
114 if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) {
115 t.Fatal(err)
116 }
117}
118
119// One process with windows on two outputs (a terminal server): the focused
120// window wins, else a visible one.
121func TestOutputOfPrefersFocusedWindow(t *testing.T) {
122 st := sample
123 st.Windows = []Window{
124 {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true},
125 {ID: 2, PID: 100, Output: "DP-2", Visible: false},
126 {ID: 3, PID: 100, Output: "DP-2", Visible: true},
127 }
128 if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" {
129 t.Fatal(o)
130 }
131 st.Window = &st.Windows[2]
132 if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" {
133 t.Fatal(o)
134 }
135}
136
137func TestLinuxProcTreeParent(t *testing.T) {
138 if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() {
139 t.Fatal(ppid, ok)
140 }
141}
142
143// A configured workspace is named after its configured name, any other one
144// after the launch prefix and its core ID: the string of ext_workspace_handle_v1.
145func TestWorkspaceIDStrings(t *testing.T) {
146 ids := workspaceid.WithPrefix("0a1b2c3d")
147 st := fromPorts(ports.State{
148 Outputs: []ports.OutputState{{Name: "DP-2", Workspace: "web", WorkspaceID: 7}, {Name: "DP-3", WorkspaceID: 8}},
149 Windows: []ports.WindowState{{ID: 1, WorkspaceID: 7, WorkspaceName: "web"}, {ID: 2, WorkspaceID: 8}},
150 }, ids)
151 if st.Outputs[0].WorkspaceID != "name:web" || st.Outputs[1].WorkspaceID != "0a1b2c3d-8" {
152 t.Fatalf("outputs %+v", st.Outputs)
153 }
154 if st.Windows[0].WorkspaceID != "name:web" || st.Windows[1].WorkspaceID != "0a1b2c3d-8" {
155 t.Fatalf("windows %+v", st.Windows)
156 }
157 data, err := json.Marshal(st.Windows[1])
158 if err != nil || !strings.Contains(string(data), `"workspace_id":"0a1b2c3d-8"`) {
159 t.Fatalf("%s %v", data, err)
160 }
161}
162
163// Workspaces reach the file with their script-facing names.
164func TestWorkspaceGeometryFields(t *testing.T) {
165 ids := workspaceid.WithPrefix("0a1b2c3d")
166 st := fromPorts(ports.State{Workspaces: []ports.WorkspaceState{
167 {ID: 7, Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []ports.ColumnState{
168 {Rect: ports.Rect{X: 10, Y: 20, W: 300, H: 400}, Windows: []ports.TileState{{ID: 5, Rect: ports.Rect{X: 11, Y: 21, W: 298, H: 198}}}},
169 }},
170 {ID: 8, Output: "DP-2"},
171 }}, ids)
172 want := []Workspace{
173 {ID: "name:web", Number: 2, Name: "web", Output: "DP-2", Visible: true, Overflow: "fixed", Columns: []Column{
174 {X: 10, Y: 20, Width: 300, Height: 400, Windows: []Tile{{ID: 5, X: 11, Y: 21, Width: 298, Height: 198}}},
175 }},
176 {ID: "0a1b2c3d-8", Output: "DP-2", Columns: []Column{}},
177 }
178 if !reflect.DeepEqual(st.Workspaces, want) {
179 t.Fatalf("%+v", st.Workspaces)
180 }
181 data, err := json.Marshal(st)
182 if err != nil {
183 t.Fatal(err)
184 }
185 for _, key := range []string{`"workspaces":[`, `"id":"name:web"`, `"number":2`, `"visible":true`, `"overflow":"fixed"`, `"columns":[{"x":10,"y":20,"width":300,"height":400,"windows":[{"id":5,"x":11,"y":21,"width":298,"height":198}]}]`} {
186 if !strings.Contains(string(data), key) {
187 t.Fatalf("%s missing %s", data, key)
188 }
189 }
190}
191
192// The file is written at most once per second: the first state and one after
193// a quiet second at once, a burst within the second as one trailing write of
194// the latest state.
195func TestRunRateLimit(t *testing.T) {
196 synctest.Test(t, func(t *testing.T) {
197 path := filepath.Join(t.TempDir(), "s", "wayland-9.json")
198 states := make(chan ports.State)
199 ctx, cancel := context.WithCancel(context.Background())
200 done := make(chan error, 1)
201 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
202 output := func() string {
203 t.Helper()
204 synctest.Wait()
205 st, err := Read(path)
206 if err != nil {
207 return ""
208 }
209 return st.Output
210 }
211 send := func(name string) { states <- ports.State{Output: name} }
212
213 send("a")
214 if got := output(); got != "a" {
215 t.Fatalf("first state not written at once: %q", got)
216 }
217 // A burst within the second: nothing yet, then the latest only.
218 time.Sleep(200 * time.Millisecond)
219 send("b")
220 send("c")
221 send("d")
222 if got := output(); got != "a" {
223 t.Fatalf("written within the second: %q", got)
224 }
225 time.Sleep(799 * time.Millisecond)
226 if got := output(); got != "a" {
227 t.Fatalf("written before the second ended: %q", got)
228 }
229 time.Sleep(time.Millisecond)
230 if got := output(); got != "d" {
231 t.Fatalf("trailing write: %q, want d", got)
232 }
233 // Nothing more is pending: the file stays as it is.
234 if err := os.Remove(path); err != nil {
235 t.Fatal(err)
236 }
237 time.Sleep(5 * time.Second)
238 if got := output(); got != "" {
239 t.Fatalf("a write with no new state: %q", got)
240 }
241 // After a quiet period the next state is immediate.
242 send("e")
243 if got := output(); got != "e" {
244 t.Fatalf("state after a quiet period: %q", got)
245 }
246 cancel()
247 if err := <-done; err != nil {
248 t.Fatal(err)
249 }
250 if _, err := os.Stat(path); !os.IsNotExist(err) {
251 t.Fatal("state file left behind", err)
252 }
253 })
254}