1package statefile
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "reflect"
10 "strings"
11 "testing"
12 "time"
13
14 "github.com/bnema/neferwl/internal/adapters/workspaceid"
15 "github.com/bnema/neferwl/internal/ports"
16 "github.com/bnema/zerowrap"
17)
18
19var sample = State{
20 Output: "DP-2",
21 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"}},
22 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}},
23}
24
25// The file holds the latest state while the session runs; a stale file from
26// a crashed session goes at start, and the file is removed at exit.
27func TestRunWritesAndRemoves(t *testing.T) {
28 path, err := Path(t.TempDir(), "wayland-9")
29 if err != nil {
30 t.Fatal(err)
31 }
32 if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
33 t.Fatal(err)
34 }
35 if err := os.WriteFile(path, []byte(`{"output":"stale"}`), 0o600); err != nil {
36 t.Fatal(err)
37 }
38 states := make(chan ports.State, 1)
39 ctx, cancel := context.WithCancel(context.Background())
40 done := make(chan error, 1)
41 go func() { done <- Run(ctx, path, states, workspaceid.WithPrefix("0a1b2c3d"), zerowrap.Default()) }()
42 focused := ports.WindowState{ID: 2, AppID: "foot", PID: 200, Output: "DP-2", Workspace: 1, WorkspaceID: 9, Visible: true}
43 states <- ports.State{Output: "DP-2", Outputs: []ports.OutputState{{Name: "DP-2", Active: 1, Count: 1, WorkspaceID: 9}}, Window: &focused, Windows: []ports.WindowState{focused}}
44 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}}}
45 deadline := time.Now().Add(2 * time.Second)
46 for {
47 if got, err := Read(path); err == nil && got.Output != "stale" {
48 if !reflect.DeepEqual(got, want) {
49 t.Fatalf("%+v", got)
50 }
51 break
52 }
53 if time.Now().After(deadline) {
54 t.Fatal("no state file")
55 }
56 time.Sleep(5 * time.Millisecond)
57 }
58 cancel()
59 if err := <-done; err != nil {
60 t.Fatal(err)
61 }
62 if _, err := os.Stat(path); !os.IsNotExist(err) {
63 t.Fatal("state file left behind", err)
64 }
65}
66
67// Stash fields reach the file with their script-facing names.
68func TestWindowStashFields(t *testing.T) {
69 got := window(ports.WindowState{ID: 3, Floating: true, StashIndex: 2, StashCount: 4, Hidden: true}, workspaceid.WithPrefix("0a1b2c3d"))
70 if !got.Floating || got.StashIndex != 2 || got.StashCount != 4 || !got.Hidden {
71 t.Fatalf("%+v", got)
72 }
73 data, err := json.Marshal(got)
74 if err != nil {
75 t.Fatal(err)
76 }
77 for _, key := range []string{`"floating":true`, `"stash_index":2`, `"stash_count":4`, `"hidden":true`} {
78 if !strings.Contains(string(data), key) {
79 t.Fatalf("%s missing %s", data, key)
80 }
81 }
82}
83
84func TestPath(t *testing.T) {
85 if p, err := Path("/run/user/1", "/run/user/1/wayland-1"); err != nil || p != "/run/user/1/neferwl/wayland-1.json" {
86 t.Fatal(p, err)
87 }
88 for _, c := range [][2]string{{"", "wayland-1"}, {"/run/user/1", ""}} {
89 if _, err := Path(c[0], c[1]); err == nil {
90 t.Fatal(c)
91 }
92 }
93}
94
95// A shell finds its window through its ancestors.
96func TestOutputOfWalksParents(t *testing.T) {
97 tree := newMockprocTree(t)
98 // shell 300 → login 250 → terminal 200 (window on DP-2)
99 tree.EXPECT().Parent(300).Return(250, true)
100 tree.EXPECT().Parent(250).Return(200, true)
101 o, err := outputOf(sample, 300, tree)
102 if err != nil || o.Name != "DP-2" || o.Active != 2 || o.Count != 3 {
103 t.Fatal(o, err)
104 }
105 // The window's own process needs no walk.
106 if o, err := outputOf(sample, 100, newMockprocTree(t)); err != nil || o.Name != "HDMI-A-1" {
107 t.Fatal(o, err)
108 }
109 // No window up to init.
110 orphan := newMockprocTree(t)
111 orphan.EXPECT().Parent(400).Return(1, true)
112 orphan.EXPECT().Parent(1).Return(0, false)
113 if _, err := outputOf(sample, 400, orphan); !errors.Is(err, ErrNoWindow) {
114 t.Fatal(err)
115 }
116}
117
118// One process with windows on two outputs (a terminal server): the focused
119// window wins, else a visible one.
120func TestOutputOfPrefersFocusedWindow(t *testing.T) {
121 st := sample
122 st.Windows = []Window{
123 {ID: 1, PID: 100, Output: "HDMI-A-1", Visible: true},
124 {ID: 2, PID: 100, Output: "DP-2", Visible: false},
125 {ID: 3, PID: 100, Output: "DP-2", Visible: true},
126 }
127 if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "HDMI-A-1" {
128 t.Fatal(o)
129 }
130 st.Window = &st.Windows[2]
131 if o, _ := outputOf(st, 100, newMockprocTree(t)); o.Name != "DP-2" {
132 t.Fatal(o)
133 }
134}
135
136func TestLinuxProcTreeParent(t *testing.T) {
137 if ppid, ok := (linuxProcTree{}).Parent(os.Getpid()); !ok || ppid != os.Getppid() {
138 t.Fatal(ppid, ok)
139 }
140}
141
142// A configured workspace is named after its configured name, any other one
143// after the launch prefix and its core ID: the string of ext_workspace_handle_v1.
144func TestWorkspaceIDStrings(t *testing.T) {
145 ids := workspaceid.WithPrefix("0a1b2c3d")
146 st := fromPorts(ports.State{
147 Outputs: []ports.OutputState{{Name: "DP-2", Workspace: "web", WorkspaceID: 7}, {Name: "DP-3", WorkspaceID: 8}},
148 Windows: []ports.WindowState{{ID: 1, WorkspaceID: 7, WorkspaceName: "web"}, {ID: 2, WorkspaceID: 8}},
149 }, ids)
150 if st.Outputs[0].WorkspaceID != "name:web" || st.Outputs[1].WorkspaceID != "0a1b2c3d-8" {
151 t.Fatalf("outputs %+v", st.Outputs)
152 }
153 if st.Windows[0].WorkspaceID != "name:web" || st.Windows[1].WorkspaceID != "0a1b2c3d-8" {
154 t.Fatalf("windows %+v", st.Windows)
155 }
156 data, err := json.Marshal(st.Windows[1])
157 if err != nil || !strings.Contains(string(data), `"workspace_id":"0a1b2c3d-8"`) {
158 t.Fatalf("%s %v", data, err)
159 }
160}