v0.4.0 b6be3a8

workspaces_test.go RawHistoryFolder

1package wayland
2
3import (
4	"context"
5	"slices"
6	"sync"
7	"testing"
8	"time"
9
10	ext "github.com/bnema/go-wayland-bindings/server/extworkspace"
11	"github.com/bnema/neferwl/internal/adapters/logging"
12	"github.com/bnema/neferwl/internal/adapters/workspaceid"
13	"github.com/bnema/neferwl/internal/ports"
14	"github.com/bnema/wlturbo"
15)
16
17// workspaceEventOrder records dispatch order across manager and child objects.
18type workspaceEventOrder struct {
19	mu     sync.Mutex
20	events []uint32
21}
22
23func (o *workspaceEventOrder) add(v uint32) {
24	if o != nil {
25		o.mu.Lock()
26		o.events = append(o.events, v)
27		o.mu.Unlock()
28	}
29}
30func (o *workspaceEventOrder) take() []uint32 {
31	o.mu.Lock()
32	defer o.mu.Unlock()
33	v := slices.Clone(o.events)
34	o.events = nil
35	return v
36}
37
38type workspaceEvents struct {
39	wlturbo.BaseProxy
40	client   *wlturbo.Display
41	events   chan [2]uint32
42	idEvents chan string
43	order    *workspaceEventOrder
44}
45
46func (p *workspaceEvents) Dispatch(e *wlturbo.Event) {
47	v := [2]uint32{uint32(e.Opcode), 0}
48	if e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup) || e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspace) {
49		v[1] = e.Uint32()
50		child := &workspaceChildEvents{events: p.events, idEvents: p.idEvents, order: p.order, group: e.Opcode == uint16(ext.ExtWorkspaceManagerV1EventWorkspaceGroup)}
51		child.SetID(v[1])
52		registerWireProxy(p.client, child)
53	}
54	p.order.add(v[0])
55	p.events <- v
56}
57
58type workspaceChildEvents struct {
59	wlturbo.BaseProxy
60	events   chan [2]uint32
61	group    bool
62	idEvents chan string
63	order    *workspaceEventOrder
64}
65
66func (p *workspaceChildEvents) Dispatch(e *wlturbo.Event) {
67	// Encode the interface in the upper byte to distinguish manager, group and handle.
68	kind := uint32(2)
69	if p.group {
70		kind = 1
71	}
72	v := [2]uint32{kind<<8 | uint32(e.Opcode), 0}
73	switch {
74	case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventOutputLeave)):
75		v[1] = e.Uint32()
76	case p.group && (e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter) || e.Opcode == uint16(ext.ExtWorkspaceGroupHandleV1EventWorkspaceLeave)):
77		v[1] = e.Uint32()
78	case !p.group && (e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventState) || e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventCapabilities)):
79		v[1] = e.Uint32()
80	case !p.group && e.Opcode == uint16(ext.ExtWorkspaceHandleV1EventId):
81		p.idEvents <- e.String()
82	}
83	p.order.add(v[0])
84	p.events <- v
85}
86func TestWorkspaceProtocol(t *testing.T) {
87	dir := t.TempDir()
88	events := make(chan ports.ClientEvent, 16)
89	snapshots := make(chan ports.Workspaces, 1)
90	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Events: events, Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
91	if err != nil {
92		t.Fatal(err)
93	}
94	ctx, cancel := context.WithCancel(context.Background())
95	done := make(chan error, 1)
96	go func() { done <- s.Run(ctx) }()
97	t.Cleanup(func() {
98		cancel()
99		if err := <-done; err != nil {
100			t.Error(err)
101		}
102	})
103	c := protocolClient(t, s, dir)
104	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "2", Index: 1}}}}}
105	if !s.display.Do(func() {}) {
106		t.Fatal("display stopped")
107	}
108	managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
109	p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16)}
110	p.SetID(managerID)
111	registerWireProxy(c, p)
112	var handle, secondHandle uint32
113	for i := 0; i < 8 && secondHandle == 0; i++ {
114		if err := c.Roundtrip(); err != nil {
115			t.Fatal(err)
116		}
117		for len(p.events) > 0 {
118			v := <-p.events
119			if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
120				if handle == 0 {
121					handle = v[1]
122				} else {
123					secondHandle = v[1]
124				}
125			}
126		}
127	}
128	if handle == 0 {
129		t.Fatal("workspace not announced")
130	}
131	// A dynamic workspace sends "<launch prefix>-<core ID>".
132	select {
133	case id := <-p.idEvents:
134		if id != "0a1b2c3d-42" {
135			t.Fatalf("workspace id %q, want 0a1b2c3d-42", id)
136		}
137	default:
138		t.Fatal("workspace sent no id")
139	}
140	out := bindProtocol(t, c, "wl_output")
141	registerProtocol(t, c, out)
142	entered := false
143	doneCount := 0
144	for i := 0; i < 8 && !entered; i++ {
145		if err := c.Roundtrip(); err != nil {
146			t.Fatal(err)
147		}
148		for len(p.events) > 0 {
149			v := <-p.events
150			if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
151				entered = true
152			}
153			if v[0] == ext.ExtWorkspaceManagerV1EventDone {
154				doneCount++
155			}
156		}
157	}
158	if err := c.Roundtrip(); err != nil {
159		t.Fatal(err)
160	}
161	for len(p.events) > 0 {
162		if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
163			doneCount++
164		}
165	}
166	if !entered || doneCount != 1 {
167		t.Fatalf("late output enter=%v, done=%d", entered, doneCount)
168	}
169	if len(p.idEvents) != 1 { // the second workspace's; the first was read above
170		t.Fatalf("id events left = %d, want 1", len(p.idEvents))
171	}
172	requestProtocol(t, c, handle, ext.ExtWorkspaceHandleV1RequestActivate)
173	requestProtocol(t, c, secondHandle, ext.ExtWorkspaceHandleV1RequestActivate)
174	if err := c.Roundtrip(); err != nil {
175		t.Fatal(err)
176	}
177	select {
178	case v := <-events:
179		t.Fatalf("activation before commit: %v", v)
180	default:
181	}
182	requestProtocol(t, c, managerID, ext.ExtWorkspaceManagerV1RequestCommit)
183	if err := c.Roundtrip(); err != nil {
184		t.Fatal(err)
185	}
186	select {
187	case v := <-events:
188		if got, ok := v.(ports.WorkspaceActivate); !ok || !slices.Equal(got.IDs, []uint64{42, 43}) {
189			t.Fatal(v)
190		}
191	case <-time.After(time.Second):
192		t.Fatal("no activation")
193	}
194	select {
195	case v := <-events:
196		t.Fatalf("extra activation message: %v", v)
197	default:
198	}
199	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Hidden: true}, {ID: 43, Name: "2", Index: 1}}}}}
200	// The snapshot reaches the display through a forwarding goroutine:
201	// poll by round trips until a deadline, not a fixed count.
202	found := false
203	for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
204		if err := c.Roundtrip(); err != nil {
205			t.Fatal(err)
206		}
207		for len(p.events) > 0 {
208			v := <-p.events
209			if v == [2]uint32{2<<8 | ext.ExtWorkspaceHandleV1EventState, uint32(ext.ExtWorkspaceHandleV1StateHidden)} {
210				found = true
211			}
212		}
213	}
214	if !found {
215		t.Fatal("no state change")
216	}
217	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
218	found = false
219	for deadline := time.Now().Add(2 * time.Second); !found && time.Now().Before(deadline); time.Sleep(time.Millisecond) {
220		if err := c.Roundtrip(); err != nil {
221			t.Fatal(err)
222		}
223		for len(p.events) > 0 {
224			v := <-p.events
225			if v[0] == 1<<8|ext.ExtWorkspaceGroupHandleV1EventRemoved {
226				found = true
227			}
228		}
229	}
230	if !found {
231		t.Fatal("group not removed")
232	}
233}
234
235func TestWorkspaceConfiguredIDAndBatchDone(t *testing.T) {
236	dir := t.TempDir()
237	snapshots := make(chan ports.Workspaces, 1)
238	s, err := New(Options{RuntimeDir: dir, Outputs: testOutputs, WorkspaceIDs: workspaceid.WithPrefix("0a1b2c3d")}, Channels{Workspaces: snapshots}, logging.For(context.Background(), "wayland"))
239	if err != nil {
240		t.Fatal(err)
241	}
242	ctx, cancel := context.WithCancel(context.Background())
243	done := make(chan error, 1)
244	go func() { done <- s.Run(ctx) }()
245	t.Cleanup(func() {
246		cancel()
247		if err := <-done; err != nil {
248			t.Error(err)
249		}
250	})
251	c := protocolClient(t, s, dir)
252	// Bind manager first; the initial two-output batch has exactly one done.
253	managerID := bindProtocol(t, c, "ext_workspace_manager_v1")
254	p := &workspaceEvents{client: c, events: make(chan [2]uint32, 128), idEvents: make(chan string, 16), order: &workspaceEventOrder{}}
255	p.SetID(managerID)
256	registerWireProxy(c, p)
257	if err := c.Roundtrip(); err != nil {
258		t.Fatal(err)
259	}
260	for len(p.events) > 0 {
261		<-p.events
262	}
263	p.order.take()
264	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{
265		{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}},
266		{Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}},
267	}}
268	// Synchronize with the display owner's processing of the channel snapshot.
269	for i := 0; i < 8; i++ {
270		if !s.display.Do(func() {}) {
271			t.Fatal("display stopped")
272		}
273		if err := c.Roundtrip(); err != nil {
274			t.Fatal(err)
275		}
276		if len(p.events) > 0 {
277			break
278		}
279	}
280	doneCount := 0
281	for len(p.events) > 0 {
282		if (<-p.events)[0] == ext.ExtWorkspaceManagerV1EventDone {
283			doneCount++
284		}
285	}
286	if doneCount != 1 {
287		t.Fatalf("snapshot done=%d, want 1", doneCount)
288	}
289	order := p.order.take()
290	// The second group begins after the first group's workspaces are fully
291	// initialized; workspace_enter follows all initial workspace details.
292	wantOrder := []uint32{
293		ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
294		ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
295		ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
296		ext.ExtWorkspaceManagerV1EventWorkspaceGroup, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventCapabilities,
297		ext.ExtWorkspaceManagerV1EventWorkspace, 2<<8 | ext.ExtWorkspaceHandleV1EventId, 2<<8 | ext.ExtWorkspaceHandleV1EventName, 2<<8 | ext.ExtWorkspaceHandleV1EventCoordinates, 2<<8 | ext.ExtWorkspaceHandleV1EventState, 2<<8 | ext.ExtWorkspaceHandleV1EventCapabilities, 1<<8 | ext.ExtWorkspaceGroupHandleV1EventWorkspaceEnter,
298		ext.ExtWorkspaceManagerV1EventDone,
299	}
300	if !slices.Equal(order, wantOrder) {
301		t.Fatalf("creation order = %v, want %v", order, wantOrder)
302	}
303	// A configured workspace sends "name:<name>", the others the launch
304	// prefix and their core ID, in creation order.
305	for _, want := range []string{"0a1b2c3d-42", "name:dev", "0a1b2c3d-44"} {
306		select {
307		case id := <-p.idEvents:
308			if id != want {
309				t.Fatalf("id=%q, want %q", id, want)
310			}
311		default:
312			t.Fatalf("workspace %s has no id", want)
313		}
314	}
315	if len(p.idEvents) != 0 {
316		t.Fatal("extra id")
317	}
318	out := bindProtocol(t, c, "wl_output")
319	registerProtocol(t, c, out)
320	entered, outputDone := 0, 0
321	for i := 0; i < 8 && outputDone == 0; i++ {
322		if err := c.Roundtrip(); err != nil {
323			t.Fatal(err)
324		}
325		for len(p.events) > 0 {
326			v := <-p.events
327			if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, out} {
328				entered++
329			}
330			if v[0] == ext.ExtWorkspaceManagerV1EventDone {
331				outputDone++
332			}
333		}
334	}
335	if entered != 1 || outputDone != 1 {
336		t.Fatalf("late output enter=%d done=%d", entered, outputDone)
337	}
338	second := bindProtocol(t, c, "wl_output")
339	registerProtocol(t, c, second)
340	entered, outputDone = 0, 0
341	for i := 0; i < 8 && outputDone == 0; i++ {
342		if err := c.Roundtrip(); err != nil {
343			t.Fatal(err)
344		}
345		for len(p.events) > 0 {
346			v := <-p.events
347			if v == [2]uint32{1<<8 | ext.ExtWorkspaceGroupHandleV1EventOutputEnter, second} {
348				entered++
349			}
350			if v[0] == ext.ExtWorkspaceManagerV1EventDone {
351				outputDone++
352			}
353		}
354	}
355	if entered != 1 || outputDone != 1 {
356		t.Fatalf("second output enter=%d done=%d", entered, outputDone)
357	}
358	// A client may destroy a handle without removing the underlying workspace.
359	// A subsequent snapshot must not recreate the destroyed protocol object.
360	var group, workspace uint32
361	// Inspect server resources on the display owner's goroutine.
362	if !s.display.Do(func() {
363		m := s.workspaceManagers[0]
364		group = m.groups["HEADLESS-1"].res.ID()
365		workspace = m.handles[42].res.ID()
366	}) {
367		t.Fatal("display stopped")
368	}
369	requestProtocol(t, c, workspace, ext.ExtWorkspaceHandleV1RequestDestroy)
370	requestProtocol(t, c, group, ext.ExtWorkspaceGroupHandleV1RequestDestroy)
371	if err := c.Roundtrip(); err != nil {
372		t.Fatal(err)
373	}
374	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "1", Active: true}}}}}
375	for i := 0; i < 8; i++ {
376		if !s.display.Do(func() {}) {
377			t.Fatal("display stopped")
378		}
379		if err := c.Roundtrip(); err != nil {
380			t.Fatal(err)
381		}
382	}
383	for len(p.events) > 0 {
384		v := <-p.events
385		if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
386			t.Fatalf("recreated client-destroyed handle: %v", v)
387		}
388	}
389	// A configured rename is a removed handle and a new one (the id is the
390	// configured name), on the still-live second group. The destroyed first
391	// group remains a tombstone until it disappears.
392	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "one", Active: true}, {ID: 43, Name: "dev", Configured: "dev", Index: 1, Hidden: true}}}, {Name: "HEADLESS-2", Workspaces: []ports.WorkspaceInfo{{ID: 44, Name: "code", Configured: "code", Active: true}}}}}
393	replaced, removed := 0, 0
394	for i := 0; i < 8 && replaced == 0; i++ {
395		if !s.display.Do(func() {}) {
396			t.Fatal("display stopped")
397		}
398		if err := c.Roundtrip(); err != nil {
399			t.Fatal(err)
400		}
401		for len(p.events) > 0 {
402			v := <-p.events
403			if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace {
404				replaced++
405			}
406			if v[0] == 2<<8|ext.ExtWorkspaceHandleV1EventRemoved {
407				removed++
408			}
409		}
410	}
411	if replaced != 1 || removed != 1 {
412		t.Fatalf("rename replaced=%d removed=%d", replaced, removed)
413	}
414	select {
415	case id := <-p.idEvents:
416		if id != "name:code" {
417			t.Fatalf("renamed id=%q", id)
418		}
419	default:
420		t.Fatal("missing renamed id")
421	}
422	if len(p.idEvents) != 0 {
423		t.Fatal("extra id")
424	}
425	// Removing the group clears its tombstone, allowing a new handle on return.
426	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{}}
427	for i := 0; i < 8; i++ {
428		if !s.display.Do(func() {}) {
429			t.Fatal("display stopped")
430		}
431		if err := c.Roundtrip(); err != nil {
432			t.Fatal(err)
433		}
434	}
435	for len(p.events) > 0 {
436		<-p.events
437	}
438	snapshots <- ports.Workspaces{Outputs: []ports.WorkspaceOutput{{Name: "HEADLESS-1", Workspaces: []ports.WorkspaceInfo{{ID: 42, Name: "1", Active: true}}}}}
439	recreated := 0
440	for i := 0; i < 8 && recreated < 2; i++ {
441		if !s.display.Do(func() {}) {
442			t.Fatal("display stopped")
443		}
444		if err := c.Roundtrip(); err != nil {
445			t.Fatal(err)
446		}
447		for len(p.events) > 0 {
448			v := <-p.events
449			if v[0] == ext.ExtWorkspaceManagerV1EventWorkspace || v[0] == ext.ExtWorkspaceManagerV1EventWorkspaceGroup {
450				recreated++
451			}
452		}
453	}
454	if recreated != 2 {
455		t.Fatalf("new group and workspace after disappearance: %d", recreated)
456	}
457}

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