main f2d166b

statefile_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 25

Tags 10

↑↓ moveenter openesc close