1package wayland
2
3import (
4 "context"
5 "strconv"
6 "testing"
7 "time"
8
9 "github.com/stretchr/testify/require"
10
11 "github.com/bnema/nefercap/internal/adapters/wayland/testserver"
12 "github.com/bnema/nefercap/internal/ports"
13)
14
15func wsConfig(ws ...testserver.WorkspaceSpec) testserver.Config {
16 return testserver.Config{
17 Outputs: []testserver.OutputSpec{{Name: "TEST-1", Width: 8, Height: 4, Scale: 1}},
18 HasWorkspaces: true, Workspaces: ws,
19 }
20}
21
22func listWorkspaces(t *testing.T, s *Source) []ports.Workspace {
23 t.Helper()
24 ws, err := s.Workspaces(context.Background())
25 require.NoError(t, err)
26 return ws
27}
28
29func TestWorkspacesAbsent(t *testing.T) {
30 s, _ := newSource(t, testserver.Config{})
31 require.Nil(t, listWorkspaces(t, s))
32 require.False(t, s.Capabilities().Workspaces)
33}
34
35func TestWorkspacesListing(t *testing.T) {
36 s, _ := newSource(t, wsConfig(
37 testserver.WorkspaceSpec{ID: "name:dev", Name: "dev", Output: "TEST-1", Active: true},
38 testserver.WorkspaceSpec{ID: "7f3a-12", Name: "2", Output: "TEST-1"},
39 testserver.WorkspaceSpec{ID: "7f3a-13", Name: "3", Output: "TEST-1", Hidden: true},
40 ))
41 out := firstOutput(t, s)
42 ws := listWorkspaces(t, s)
43 require.Equal(t, []ports.Workspace{
44 {ID: 1, OutputID: out.ID, Name: "dev", Region: ports.Region{Width: 8, Height: 4}, Active: true},
45 {ID: 2, OutputID: out.ID, Name: "2", Region: ports.Region{Width: 8, Height: 4}},
46 {ID: 3, OutputID: out.ID, Name: "3", Region: ports.Region{Width: 8, Height: 4}},
47 }, ws, "id strings are interned into Source-local numbers, in announcement order")
48 require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities(), "no hidden workspaces without the extension")
49}
50
51func TestWorkspaceIdentityOpaqueAndTemporary(t *testing.T) {
52 s, _ := newSource(t, wsConfig(
53 testserver.WorkspaceSpec{ID: "name:main", Name: "main", Output: "TEST-1", Active: true},
54 testserver.WorkspaceSpec{Name: "tmp1", Output: "TEST-1"},
55 testserver.WorkspaceSpec{Name: "tmp2", Output: "TEST-1"},
56 ))
57 ws := listWorkspaces(t, s)
58 require.Len(t, ws, 3)
59 seen := map[uint64]bool{}
60 for _, w := range ws {
61 require.NotZero(t, w.ID)
62 require.False(t, seen[w.ID], "identities are unique")
63 seen[w.ID] = true
64 }
65 again := listWorkspaces(t, s)
66 require.Equal(t, ws, again, "identities are stable for the connection")
67}
68
69func TestWorkspaceStateChangesAndRemoval(t *testing.T) {
70 s, srv := newSource(t, wsConfig(
71 testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
72 testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"},
73 ))
74 srv.SetActive("a", false)
75 srv.SetActive("b", true)
76 ws := listWorkspaces(t, s)
77 require.False(t, ws[0].Active)
78 require.True(t, ws[1].Active)
79 srv.RemoveWorkspace("a")
80 ws = listWorkspaces(t, s)
81 require.Len(t, ws, 1)
82 require.Equal(t, "b", ws[0].Name)
83}
84
85func TestWorkspaceCaptureActiveOnlyWithoutExtension(t *testing.T) {
86 s, srv := newSource(t, wsConfig(
87 testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
88 testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1"},
89 ))
90 out := firstOutput(t, s)
91 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1})
92 require.NoError(t, err)
93 require.Equal(t, [2]int{8, 4}, [2]int{f.Width, f.Height})
94 require.Equal(t, "output", srv.Stats().LastSource, "the active workspace is captured as its output")
95 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
96 require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable, "a hidden workspace is never captured as the output")
97 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 99})
98 require.ErrorIs(t, err, ports.ErrWorkspaceUnavailable)
99 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 1, Region: ports.Region{Width: 1, Height: 1}})
100 require.ErrorIs(t, err, ports.ErrInvalidRegion)
101}
102
103func TestWorkspaceCaptureUsesExtensionSource(t *testing.T) {
104 cfg := wsConfig(
105 testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true},
106 testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true},
107 )
108 cfg.NeferwlSource = true
109 s, srv := newSource(t, cfg)
110 require.Equal(t, ports.Capabilities{Workspaces: true}, s.Capabilities())
111 out := firstOutput(t, s)
112 f, err := s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
113 require.NoError(t, err, "a hidden workspace is captured through the extension")
114 require.NoError(t, f.Validate())
115 require.Equal(t, "workspace", srv.Stats().LastSource)
116 // it follows the workspace: still captured after the active one changes
117 srv.SetActive("a", false)
118 srv.SetActive("b", true)
119 _, err = s.Capture(context.Background(), ports.Target{OutputID: out.ID, WorkspaceID: 2})
120 require.NoError(t, err)
121 require.Equal(t, 1, srv.Stats().Sessions, "the same session serves the workspace")
122}
123
124// With NeferWL's extension a workspace lists its own frame, not the output.
125func TestWorkspaceRegionIsTheFrameFromTheExtension(t *testing.T) {
126 cfg := wsConfig(
127 testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}},
128 testserver.WorkspaceSpec{ID: "7f3a-2", Name: "b", Output: "TEST-1", Hidden: true, Frame: [4]int32{0, 0, 8, 4}},
129 testserver.WorkspaceSpec{ID: "7f3a-3", Name: "c", Output: "TEST-1", Frame: [4]int32{6, 3, 9, 9}},
130 testserver.WorkspaceSpec{ID: "7f3a-4", Name: "d", Output: "TEST-1"},
131 )
132 cfg.NeferwlSource = true
133 s, srv := newSource(t, cfg)
134 regions := func() map[string]ports.Region {
135 m := map[string]ports.Region{}
136 for _, w := range listWorkspaces(t, s) {
137 m[w.Name] = w.Region
138 }
139 return m
140 }
141 require.Equal(t, map[string]ports.Region{
142 "a": {X: 2, Y: 1, Width: 4, Height: 2},
143 "b": {Width: 8, Height: 4},
144 "c": {X: 6, Y: 3, Width: 2, Height: 1}, // clipped to the output
145 "d": {Width: 8, Height: 4}, // no frame sent: the whole output
146 }, regions())
147 require.Equal(t, 4, srv.Stats().WorkspaceFrames)
148 // Listing again asks for nothing more, and a change is followed.
149 srv.SetFrame("a", [4]int32{1, 1, 3, 3})
150 require.Equal(t, ports.Region{X: 1, Y: 1, Width: 3, Height: 3}, regions()["a"])
151 require.Equal(t, 4, srv.Stats().WorkspaceFrames)
152 // A removed workspace releases its frame object.
153 srv.RemoveWorkspace("a")
154 require.NotContains(t, regions(), "a")
155 require.Eventually(t, func() bool { return srv.Stats().DestroyedWorkspaceFrames == 1 }, 2*time.Second, time.Millisecond)
156}
157
158// An off-screen frame, or one that is nowhere on the output, falls back to the
159// whole output rather than offering an empty workspace.
160func TestWorkspaceRegionFallsBackToTheOutput(t *testing.T) {
161 cfg := wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{20, 20, 4, 4}})
162 cfg.NeferwlSource = true
163 s, _ := newSource(t, cfg)
164 require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region)
165}
166
167func TestWorkspaceRegionIsTheOutputWithoutTheExtension(t *testing.T) {
168 s, srv := newSource(t, wsConfig(testserver.WorkspaceSpec{ID: "name:a", Name: "a", Output: "TEST-1", Active: true, Frame: [4]int32{2, 1, 4, 2}}))
169 require.Equal(t, ports.Region{Width: 8, Height: 4}, listWorkspaces(t, s)[0].Region)
170 require.Zero(t, srv.Stats().WorkspaceFrames)
171}
172
173func TestWorkspaceLimit(t *testing.T) {
174 var specs []testserver.WorkspaceSpec
175 for i := range maxWorkspaces + 4 {
176 specs = append(specs, testserver.WorkspaceSpec{ID: strconv.Itoa(i + 1), Name: "w", Output: "TEST-1"})
177 }
178 s, _ := newSource(t, wsConfig(specs...))
179 require.Nil(t, listWorkspaces(t, s), "an inventory beyond the bound is rejected, not truncated")
180}
181
182// The id event is an opaque string: it is interned, never parsed.
183func TestWorkspaceIdentityIsInterned(t *testing.T) {
184 ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}}
185 a, b := ws.intern("7f3a-5"), ws.intern("name:5")
186 require.NotEqual(t, a, b, "both id forms are just strings")
187 require.Equal(t, a, ws.intern("7f3a-5"), "the same string keeps its identity")
188 require.Equal(t, uint64(1), a, "identities come from one counter")
189 require.Equal(t, uint64(2), b)
190 require.NotContains(t, []uint64{a, b}, ws.intern("5"), "a bare decimal is another string too")
191 ws.byID[a] = &workspace{}
192 dup := ws.intern("7f3a-5")
193 require.NotEqual(t, a, dup, "an id held by a live workspace is not shared")
194 require.Equal(t, uint64(4), dup)
195 t1, t2 := ws.intern(""), ws.intern("")
196 require.NotEqual(t, t1, t2, "workspaces without an id are temporary")
197 delete(ws.byID, a)
198 require.Equal(t, a, ws.intern("7f3a-5"), "a returning workspace gets its identity back")
199 require.False(t, validName("a\nb"))
200 require.False(t, validName("\u202e"))
201 require.True(t, validName("Bureau 2"))
202}
203
204func TestWorkspaceInternTableIsBounded(t *testing.T) {
205 ws := &workspaceState{interned: map[string]uint64{}, byID: map[uint64]*workspace{}}
206 for i := range maxInterned + 10 {
207 ws.intern("p-" + strconv.Itoa(i))
208 }
209 require.Len(t, ws.interned, maxInterned, "the string table is bounded")
210}
211
212// Hotplug: an output that goes away leaves no stale group entry, and no
213// workspace is listed on it.
214func TestOutputHotplugPurgesWorkspaceAndXdgState(t *testing.T) {
215 cfg := testserver.Config{
216 Outputs: []testserver.OutputSpec{
217 {Name: "A", Width: 8, Height: 4, Scale: 1, LogicalWidth: 8, LogicalHeight: 4},
218 {Name: "B", Width: 16, Height: 8, Scale: 1, LogicalWidth: 16, LogicalHeight: 8},
219 },
220 HasWorkspaces: true,
221 Workspaces: []testserver.WorkspaceSpec{{ID: "name:a", Name: "a", Output: "A", Active: true}},
222 }
223 s, srv := newSource(t, cfg)
224 ws := listWorkspaces(t, s)
225 require.Len(t, ws, 2, "one entry per output of the group")
226 srv.RemoveOutput("B")
227 ws = listWorkspaces(t, s)
228 require.Len(t, ws, 1)
229 require.Equal(t, "A", outputNameOf(t, s, ws[0].OutputID))
230 for _, g := range s.ws.groups {
231 require.Len(t, g.outputs, 1, "the removed output proxy is purged from the group")
232 }
233 require.Equal(t, 1, srv.Stats().DestroyedXdgOutputs, "the zxdg_output_v1 of the removed output is destroyed")
234}
235
236func outputNameOf(t *testing.T, s *Source, id uint32) string {
237 t.Helper()
238 outs, err := s.Outputs(context.Background())
239 require.NoError(t, err)
240 for _, o := range outs {
241 if o.ID == id {
242 return o.Name
243 }
244 }
245 return ""
246}