Add test suite

f50d353fa9c5146abe44563bf71fbbb6f6aef457
Alexis Sellier committed ago 1 parent 686c5c01
Makefile +54 -1
30 30
HDRS  := config.h swm.h util.h $(PROTO)
31 31
32 32
WL_SCANNER := $(shell $(PKG_CONFIG) --variable=wayland_scanner wayland-scanner)
33 33
WL_PROTO   := $(shell $(PKG_CONFIG) --variable=pkgdatadir wayland-protocols)
34 34
35 +
TEST_BUILD    ?= test/.build
36 +
TEST_SRC      := $(TEST_BUILD)/source
37 +
TEST_BIN      := $(TEST_BUILD)/bin
38 +
TEST_CPPFLAGS := -I$(TEST_SRC) $(CPPFLAGS)
39 +
TEST_CFLAGS   := -std=c23 -g -O0 \
40 +
	-Wno-unused-function -Wno-unused-variable -Wno-unused-parameter
41 +
42 +
ifeq ($(TEST_COVERAGE),1)
43 +
TEST_CFLAGS += -fprofile-instr-generate -fcoverage-mapping
44 +
endif
45 +
35 46
all: man swm
36 47
37 48
man: swm.1
38 49
39 50
swm.1: swm.1.adoc
104 115
	makepkg --force
105 116
106 117
clean:
107 118
	rm -f swm swm.1 *.o *-protocol.h *-protocol.c
108 119
109 -
.PHONY: all man fmt install uninstall archive package clean
120 +
$(TEST_SRC) $(TEST_BIN):
121 +
	@mkdir -p $@
122 +
123 +
$(TEST_SRC)/swm.c: swm.c | $(TEST_SRC)
124 +
	@cp -p $< $@
125 +
126 +
$(TEST_SRC)/config.h: config.def.h | $(TEST_SRC)
127 +
	@cp -p $< $@
128 +
129 +
TEST_COMMON := Makefile config.mk $(TEST_SRC)/swm.c $(TEST_SRC)/config.h \
130 +
	swm.h util.c util.h $(PROTO) ext-workspace-v1-protocol.c
131 +
132 +
$(TEST_BIN)/unit: test/unit.c $(TEST_COMMON) | $(TEST_BIN)
133 +
	@echo "cc   test/unit.c => $@"
134 +
	@$(CC) $(TEST_CPPFLAGS) $(TEST_CFLAGS) test/unit.c util.c \
135 +
		ext-workspace-v1-protocol.c $(LDLIBS) -o $@
136 +
137 +
$(TEST_BIN)/swm: $(TEST_COMMON) | $(TEST_BIN)
138 +
	@echo "cc   swm.c => $@"
139 +
	@$(CC) $(TEST_CPPFLAGS) $(TEST_CFLAGS) $(TEST_SRC)/swm.c util.c \
140 +
		ext-workspace-v1-protocol.c $(LDLIBS) -o $@
141 +
142 +
test-build: $(TEST_BIN)/unit $(TEST_BIN)/swm
143 +
144 +
test: test-build
145 +
	@SWM_TEST_BUILD=$(abspath $(TEST_BUILD)) PKG_CONFIG=$(PKG_CONFIG) $(PYTHON) test/run.py
146 +
147 +
test-unit: $(TEST_BIN)/unit
148 +
	@SWM_TEST_BUILD=$(abspath $(TEST_BUILD)) PKG_CONFIG=$(PKG_CONFIG) $(PYTHON) test/run.py unit
149 +
150 +
test-integration: $(TEST_BIN)/swm
151 +
	@SWM_TEST_BUILD=$(abspath $(TEST_BUILD)) PKG_CONFIG=$(PKG_CONFIG) $(PYTHON) test/run.py integration
152 +
153 +
coverage:
154 +
	@$(MAKE) --no-print-directory TEST_BUILD=$(TEST_BUILD)/coverage TEST_COVERAGE=1 test-build
155 +
	@SWM_TEST_BUILD=$(abspath $(TEST_BUILD)/coverage) PKG_CONFIG=$(PKG_CONFIG) \
156 +
		$(PYTHON) test/run.py coverage
157 +
158 +
test-clean:
159 +
	@SWM_TEST_BUILD=$(abspath $(TEST_BUILD)) $(PYTHON) test/run.py clean
160 +
161 +
.PHONY: all man fmt install uninstall archive package clean coverage
162 +
.PHONY: test-build test test-unit test-integration test-clean
README +17 -5
119 119
120 120
Run the complete test suite with:
121 121
122 122
    make test
123 123
124 -
It builds unit tests and protocol clients, runs a headless nested-compositor
125 -
integration suite (including a two-output run), collects LLVM source coverage,
126 -
and requires at least 80% aggregate production-code line coverage. The test
127 -
target therefore also requires LLVM's coverage tools, a POSIX shell, and the
128 -
runtime libraries needed by the normal build.
124 +
Make builds one focused C unit-test binary and a test compositor, then the
125 +
Python runner executes them alongside native Wayland and XWayland clients,
126 +
including a two-output run. Unchanged binaries are reused; use the narrower
127 +
targets while developing:
128 +
129 +
    make test-unit
130 +
    make test-integration
131 +
132 +
Coverage is informational and has its own target:
133 +
134 +
    make coverage
135 +
136 +
Generated test files live under `test/.build/`; remove them with
137 +
`make test-clean`. All targets require Python 3 and the normal build
138 +
dependencies. Integration tests additionally require PyWayland and its protocol
139 +
scanner, xcffib, wlr-randr, and Xwayland; coverage requires LLVM's coverage
140 +
tools.
129 141
130 142
Other development targets are:
131 143
132 144
    make man       # build swm.1 from swm.1.adoc
133 145
    make fmt       # format tracked C sources with clang-format
config.mk +1 -0
1 1
VERSION    = 1.0.0
2 2
PKG_CONFIG = pkg-config
3 3
CC         = clang
4 +
PYTHON    ?= python3
4 5
PREFIX     = /usr/local
5 6
MANDIR     = $(PREFIX)/share/man
6 7
DATADIR    = $(PREFIX)/share
7 8
WLR_INCS   = `$(PKG_CONFIG) --cflags wlroots-0.20`
8 9
WLR_LIBS   = `$(PKG_CONFIG) --libs wlroots-0.20`
protocols/input-method-unstable-v2.xml added +494 -0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<protocol name="input_method_unstable_v2">
3 +
4 +
  <copyright>
5 +
    Copyright © 2008-2011 Kristian Høgsberg
6 +
    Copyright © 2010-2011 Intel Corporation
7 +
    Copyright © 2012-2013 Collabora, Ltd.
8 +
    Copyright © 2012, 2013 Intel Corporation
9 +
    Copyright © 2015, 2016 Jan Arne Petersen
10 +
    Copyright © 2017, 2018 Red Hat, Inc.
11 +
    Copyright © 2018       Purism SPC
12 +
13 +
    Permission is hereby granted, free of charge, to any person obtaining a
14 +
    copy of this software and associated documentation files (the "Software"),
15 +
    to deal in the Software without restriction, including without limitation
16 +
    the rights to use, copy, modify, merge, publish, distribute, sublicense,
17 +
    and/or sell copies of the Software, and to permit persons to whom the
18 +
    Software is furnished to do so, subject to the following conditions:
19 +
20 +
    The above copyright notice and this permission notice (including the next
21 +
    paragraph) shall be included in all copies or substantial portions of the
22 +
    Software.
23 +
24 +
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
25 +
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
26 +
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL
27 +
    THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
28 +
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
29 +
    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
30 +
    DEALINGS IN THE SOFTWARE.
31 +
  </copyright>
32 +
33 +
  <description summary="Protocol for creating input methods">
34 +
    This protocol allows applications to act as input methods for compositors.
35 +
36 +
    An input method context is used to manage the state of the input method.
37 +
38 +
    Text strings are UTF-8 encoded, their indices and lengths are in bytes.
39 +
40 +
    This document adheres to the RFC 2119 when using words like "must",
41 +
    "should", "may", etc.
42 +
43 +
    Warning! The protocol described in this file is experimental and
44 +
    backward incompatible changes may be made. Backward compatible changes
45 +
    may be added together with the corresponding interface version bump.
46 +
    Backward incompatible changes are done by bumping the version number in
47 +
    the protocol and interface names and resetting the interface version.
48 +
    Once the protocol is to be declared stable, the 'z' prefix and the
49 +
    version number in the protocol and interface names are removed and the
50 +
    interface version number is reset.
51 +
  </description>
52 +
53 +
  <interface name="zwp_input_method_v2" version="1">
54 +
    <description summary="input method">
55 +
      An input method object allows for clients to compose text.
56 +
57 +
      The objects connects the client to a text input in an application, and
58 +
      lets the client to serve as an input method for a seat.
59 +
60 +
      The zwp_input_method_v2 object can occupy two distinct states: active and
61 +
      inactive. In the active state, the object is associated to and
62 +
      communicates with a text input. In the inactive state, there is no
63 +
      associated text input, and the only communication is with the compositor.
64 +
      Initially, the input method is in the inactive state.
65 +
66 +
      Requests issued in the inactive state must be accepted by the compositor.
67 +
      Because of the serial mechanism, and the state reset on activate event,
68 +
      they will not have any effect on the state of the next text input.
69 +
70 +
      There must be no more than one input method object per seat.
71 +
    </description>
72 +
73 +
    <enum name="error">
74 +
      <entry name="role" value="0" summary="wl_surface has another role"/>
75 +
    </enum>
76 +
77 +
    <event name="activate">
78 +
      <description summary="input method has been requested">
79 +
        Notification that a text input focused on this seat requested the input
80 +
        method to be activated.
81 +
82 +
        This event serves the purpose of providing the compositor with an
83 +
        active input method.
84 +
85 +
        This event resets all state associated with previous enable, disable,
86 +
        surrounding_text, text_change_cause, and content_type events, as well
87 +
        as the state associated with set_preedit_string, commit_string, and
88 +
        delete_surrounding_text requests. In addition, it marks the
89 +
        zwp_input_method_v2 object as active, and makes any existing
90 +
        zwp_input_popup_surface_v2 objects visible.
91 +
92 +
        The surrounding_text, and content_type events must follow before the
93 +
        next done event if the text input supports the respective
94 +
        functionality.
95 +
96 +
        State set with this event is double-buffered. It will get applied on
97 +
        the next zwp_input_method_v2.done event, and stay valid until changed.
98 +
      </description>
99 +
    </event>
100 +
101 +
    <event name="deactivate">
102 +
      <description summary="deactivate event">
103 +
        Notification that no focused text input currently needs an active
104 +
        input method on this seat.
105 +
106 +
        This event marks the zwp_input_method_v2 object as inactive. The
107 +
        compositor must make all existing zwp_input_popup_surface_v2 objects
108 +
        invisible until the next activate event.
109 +
110 +
        State set with this event is double-buffered. It will get applied on
111 +
        the next zwp_input_method_v2.done event, and stay valid until changed.
112 +
      </description>
113 +
    </event>
114 +
115 +
    <event name="surrounding_text">
116 +
      <description summary="surrounding text event">
117 +
        Updates the surrounding plain text around the cursor, excluding the
118 +
        preedit text.
119 +
120 +
        If any preedit text is present, it is replaced with the cursor for the
121 +
        purpose of this event.
122 +
123 +
        The argument text is a buffer containing the preedit string, and must
124 +
        include the cursor position, and the complete selection. It should
125 +
        contain additional characters before and after these. There is a
126 +
        maximum length of wayland messages, so text can not be longer than 4000
127 +
        bytes.
128 +
129 +
        cursor is the byte offset of the cursor within the text buffer.
130 +
131 +
        anchor is the byte offset of the selection anchor within the text
132 +
        buffer. If there is no selected text, anchor must be the same as
133 +
        cursor.
134 +
135 +
        If this event does not arrive before the first done event, the input
136 +
        method may assume that the text input does not support this
137 +
        functionality and ignore following surrounding_text events.
138 +
139 +
        Values set with this event are double-buffered. They will get applied
140 +
        and set to initial values on the next zwp_input_method_v2.done
141 +
        event.
142 +
143 +
        The initial state for affected fields is empty, meaning that the text
144 +
        input does not support sending surrounding text. If the empty values
145 +
        get applied, subsequent attempts to change them may have no effect.
146 +
      </description>
147 +
      <arg name="text" type="string"/>
148 +
      <arg name="cursor" type="uint"/>
149 +
      <arg name="anchor" type="uint"/>
150 +
    </event>
151 +
152 +
    <event name="text_change_cause">
153 +
      <description summary="indicates the cause of surrounding text change">
154 +
        Tells the input method why the text surrounding the cursor changed.
155 +
156 +
        Whenever the client detects an external change in text, cursor, or
157 +
        anchor position, it must issue this request to the compositor. This
158 +
        request is intended to give the input method a chance to update the
159 +
        preedit text in an appropriate way, e.g. by removing it when the user
160 +
        starts typing with a keyboard.
161 +
162 +
        cause describes the source of the change.
163 +
164 +
        The value set with this event is double-buffered. It will get applied
165 +
        and set to its initial value on the next zwp_input_method_v2.done
166 +
        event.
167 +
168 +
        The initial value of cause is input_method.
169 +
      </description>
170 +
      <arg name="cause" type="uint" enum="zwp_text_input_v3.change_cause"/>
171 +
    </event>
172 +
173 +
    <event name="content_type">
174 +
      <description summary="content purpose and hint">
175 +
        Indicates the content type and hint for the current
176 +
        zwp_input_method_v2 instance.
177 +
178 +
        Values set with this event are double-buffered. They will get applied
179 +
        on the next zwp_input_method_v2.done event.
180 +
181 +
        The initial value for hint is none, and the initial value for purpose
182 +
        is normal.
183 +
      </description>
184 +
      <arg name="hint" type="uint" enum="zwp_text_input_v3.content_hint"/>
185 +
      <arg name="purpose" type="uint" enum="zwp_text_input_v3.content_purpose"/>
186 +
    </event>
187 +
188 +
    <event name="done">
189 +
      <description summary="apply state">
190 +
        Atomically applies state changes recently sent to the client.
191 +
192 +
        The done event establishes and updates the state of the client, and
193 +
        must be issued after any changes to apply them.
194 +
195 +
        Text input state (content purpose, content hint, surrounding text, and
196 +
        change cause) is conceptually double-buffered within an input method
197 +
        context.
198 +
199 +
        Events modify the pending state, as opposed to the current state in use
200 +
        by the input method. A done event atomically applies all pending state,
201 +
        replacing the current state. After done, the new pending state is as
202 +
        documented for each related request.
203 +
204 +
        Events must be applied in the order of arrival.
205 +
206 +
        Neither current nor pending state are modified unless noted otherwise.
207 +
      </description>
208 +
    </event>
209 +
210 +
    <request name="commit_string">
211 +
      <description summary="commit string">
212 +
        Send the commit string text for insertion to the application.
213 +
214 +
        Inserts a string at current cursor position (see commit event
215 +
        sequence). The string to commit could be either just a single character
216 +
        after a key press or the result of some composing.
217 +
218 +
        The argument text is a buffer containing the string to insert. There is
219 +
        a maximum length of wayland messages, so text can not be longer than
220 +
        4000 bytes.
221 +
222 +
        Values set with this event are double-buffered. They must be applied
223 +
        and reset to initial on the next zwp_text_input_v3.commit request.
224 +
225 +
        The initial value of text is an empty string.
226 +
      </description>
227 +
      <arg name="text" type="string"/>
228 +
    </request>
229 +
230 +
    <request name="set_preedit_string">
231 +
      <description summary="pre-edit string">
232 +
        Send the pre-edit string text to the application text input.
233 +
234 +
        Place a new composing text (pre-edit) at the current cursor position.
235 +
        Any previously set composing text must be removed. Any previously
236 +
        existing selected text must be removed. The cursor is moved to a new
237 +
        position within the preedit string.
238 +
239 +
        The argument text is a buffer containing the preedit string. There is
240 +
        a maximum length of wayland messages, so text can not be longer than
241 +
        4000 bytes.
242 +
243 +
        The arguments cursor_begin and cursor_end are counted in bytes relative
244 +
        to the beginning of the submitted string buffer. Cursor should be
245 +
        hidden by the text input when both are equal to -1.
246 +
247 +
        cursor_begin indicates the beginning of the cursor. cursor_end
248 +
        indicates the end of the cursor. It may be equal or different than
249 +
        cursor_begin.
250 +
251 +
        Values set with this event are double-buffered. They must be applied on
252 +
        the next zwp_input_method_v2.commit event.
253 +
254 +
        The initial value of text is an empty string. The initial value of
255 +
        cursor_begin, and cursor_end are both 0.
256 +
      </description>
257 +
      <arg name="text" type="string"/>
258 +
      <arg name="cursor_begin" type="int"/>
259 +
      <arg name="cursor_end" type="int"/>
260 +
    </request>
261 +
262 +
    <request name="delete_surrounding_text">
263 +
      <description summary="delete text">
264 +
        Remove the surrounding text.
265 +
266 +
        before_length and after_length are the number of bytes before and after
267 +
        the current cursor index (excluding the preedit text) to delete.
268 +
269 +
        If any preedit text is present, it is replaced with the cursor for the
270 +
        purpose of this event. In effect before_length is counted from the
271 +
        beginning of preedit text, and after_length from its end (see commit
272 +
        event sequence).
273 +
274 +
        Values set with this event are double-buffered. They must be applied
275 +
        and reset to initial on the next zwp_input_method_v2.commit request.
276 +
277 +
        The initial values of both before_length and after_length are 0.
278 +
      </description>
279 +
      <arg name="before_length" type="uint"/>
280 +
      <arg name="after_length" type="uint"/>
281 +
    </request>
282 +
283 +
    <request name="commit">
284 +
      <description summary="apply state">
285 +
        Apply state changes from commit_string, set_preedit_string and
286 +
        delete_surrounding_text requests.
287 +
288 +
        The state relating to these events is double-buffered, and each one
289 +
        modifies the pending state. This request replaces the current state
290 +
        with the pending state.
291 +
292 +
        The connected text input is expected to proceed by evaluating the
293 +
        changes in the following order:
294 +
295 +
        1. Replace existing preedit string with the cursor.
296 +
        2. Delete requested surrounding text.
297 +
        3. Insert commit string with the cursor at its end.
298 +
        4. Calculate surrounding text to send.
299 +
        5. Insert new preedit text in cursor position.
300 +
        6. Place cursor inside preedit text.
301 +
302 +
        The serial number reflects the last state of the zwp_input_method_v2
303 +
        object known to the client. The value of the serial argument must be
304 +
        equal to the number of done events already issued by that object. When
305 +
        the compositor receives a commit request with a serial different than
306 +
        the number of past done events, it must proceed as normal, except it
307 +
        should not change the current state of the zwp_input_method_v2 object.
308 +
      </description>
309 +
      <arg name="serial" type="uint"/>
310 +
    </request>
311 +
312 +
    <request name="get_input_popup_surface">
313 +
      <description summary="create popup surface">
314 +
        Creates a new zwp_input_popup_surface_v2 object wrapping a given
315 +
        surface.
316 +
317 +
        The surface gets assigned the "input_popup" role. If the surface
318 +
        already has an assigned role, the compositor must issue a protocol
319 +
        error.
320 +
      </description>
321 +
      <arg name="id" type="new_id" interface="zwp_input_popup_surface_v2"/>
322 +
      <arg name="surface" type="object" interface="wl_surface"/>
323 +
    </request>
324 +
325 +
    <request name="grab_keyboard">
326 +
      <description summary="grab hardware keyboard">
327 +
        Allow an input method to receive hardware keyboard input and process
328 +
        key events to generate text events (with pre-edit) over the wire. This
329 +
        allows input methods which compose multiple key events for inputting
330 +
        text like it is done for CJK languages.
331 +
332 +
        The compositor should send all keyboard events on the seat to the grab
333 +
        holder via the returned wl_keyboard object. Nevertheless, the
334 +
        compositor may decide not to forward any particular event. The
335 +
        compositor must not further process any event after it has been
336 +
        forwarded to the grab holder.
337 +
338 +
        Releasing the resulting wl_keyboard object releases the grab.
339 +
      </description>
340 +
      <arg name="keyboard" type="new_id"
341 +
        interface="zwp_input_method_keyboard_grab_v2"/>
342 +
    </request>
343 +
344 +
    <event name="unavailable">
345 +
      <description summary="input method unavailable">
346 +
        The input method ceased to be available.
347 +
348 +
        The compositor must issue this event as the only event on the object if
349 +
        there was another input_method object associated with the same seat at
350 +
        the time of its creation.
351 +
352 +
        The compositor must issue this request when the object is no longer
353 +
        usable, e.g. due to seat removal.
354 +
355 +
        The input method context becomes inert and should be destroyed after
356 +
        deactivation is handled. Any further requests and events except for the
357 +
        destroy request must be ignored.
358 +
      </description>
359 +
    </event>
360 +
361 +
    <request name="destroy" type="destructor">
362 +
      <description summary="destroy the text input">
363 +
        Destroys the zwp_text_input_v2 object and any associated child
364 +
        objects, i.e. zwp_input_popup_surface_v2 and
365 +
        zwp_input_method_keyboard_grab_v2.
366 +
      </description>
367 +
    </request>
368 +
  </interface>
369 +
370 +
  <interface name="zwp_input_popup_surface_v2" version="1">
371 +
    <description summary="popup surface">
372 +
      This interface marks a surface as a popup for interacting with an input
373 +
      method.
374 +
375 +
      The compositor should place it near the active text input area. It must
376 +
      be visible if and only if the input method is in the active state.
377 +
378 +
      The client must not destroy the underlying wl_surface while the
379 +
      zwp_input_popup_surface_v2 object exists.
380 +
    </description>
381 +
382 +
    <event name="text_input_rectangle">
383 +
      <description summary="set text input area position">
384 +
        Notify about the position of the area of the text input expressed as a
385 +
        rectangle in surface local coordinates.
386 +
387 +
        This is a hint to the input method telling it the relative position of
388 +
        the text being entered.
389 +
      </description>
390 +
      <arg name="x" type="int"/>
391 +
      <arg name="y" type="int"/>
392 +
      <arg name="width" type="int"/>
393 +
      <arg name="height" type="int"/>
394 +
    </event>
395 +
396 +
    <request name="destroy" type="destructor"/>
397 +
  </interface>
398 +
399 +
  <interface name="zwp_input_method_keyboard_grab_v2" version="1">
400 +
    <!-- Closely follows wl_keyboard version 6 -->
401 +
    <description summary="keyboard grab">
402 +
      The zwp_input_method_keyboard_grab_v2 interface represents an exclusive
403 +
      grab of the wl_keyboard interface associated with the seat.
404 +
    </description>
405 +
406 +
    <event name="keymap">
407 +
      <description summary="keyboard mapping">
408 +
        This event provides a file descriptor to the client which can be
409 +
        memory-mapped to provide a keyboard mapping description.
410 +
      </description>
411 +
      <arg name="format" type="uint" enum="wl_keyboard.keymap_format"
412 +
        summary="keymap format"/>
413 +
      <arg name="fd" type="fd" summary="keymap file descriptor"/>
414 +
      <arg name="size" type="uint" summary="keymap size, in bytes"/>
415 +
    </event>
416 +
417 +
    <event name="key">
418 +
      <description summary="key event">
419 +
        A key was pressed or released.
420 +
        The time argument is a timestamp with millisecond granularity, with an
421 +
        undefined base.
422 +
      </description>
423 +
      <arg name="serial" type="uint" summary="serial number of the key event"/>
424 +
      <arg name="time" type="uint" summary="timestamp with millisecond granularity"/>
425 +
      <arg name="key" type="uint" summary="key that produced the event"/>
426 +
      <arg name="state" type="uint" enum="wl_keyboard.key_state"
427 +
        summary="physical state of the key"/>
428 +
    </event>
429 +
430 +
    <event name="modifiers">
431 +
      <description summary="modifier and group state">
432 +
        Notifies clients that the modifier and/or group state has changed, and
433 +
        it should update its local state.
434 +
      </description>
435 +
      <arg name="serial" type="uint" summary="serial number of the modifiers event"/>
436 +
      <arg name="mods_depressed" type="uint" summary="depressed modifiers"/>
437 +
      <arg name="mods_latched" type="uint" summary="latched modifiers"/>
438 +
      <arg name="mods_locked" type="uint" summary="locked modifiers"/>
439 +
      <arg name="group" type="uint" summary="keyboard layout"/>
440 +
    </event>
441 +
442 +
    <request name="release" type="destructor">
443 +
      <description summary="release the grab object"/>
444 +
    </request>
445 +
446 +
    <event name="repeat_info">
447 +
      <description summary="repeat rate and delay">
448 +
        Informs the client about the keyboard's repeat rate and delay.
449 +
450 +
        This event is sent as soon as the zwp_input_method_keyboard_grab_v2
451 +
        object has been created, and is guaranteed to be received by the
452 +
        client before any key press event.
453 +
454 +
        Negative values for either rate or delay are illegal. A rate of zero
455 +
        will disable any repeating (regardless of the value of delay).
456 +
457 +
        This event can be sent later on as well with a new value if necessary,
458 +
        so clients should continue listening for the event past the creation
459 +
        of zwp_input_method_keyboard_grab_v2.
460 +
      </description>
461 +
      <arg name="rate" type="int"
462 +
	   summary="the rate of repeating keys in characters per second"/>
463 +
      <arg name="delay" type="int"
464 +
	   summary="delay in milliseconds since key down until repeating starts"/>
465 +
    </event>
466 +
  </interface>
467 +
468 +
  <interface name="zwp_input_method_manager_v2" version="1">
469 +
    <description summary="input method manager">
470 +
      The input method manager allows the client to become the input method on
471 +
      a chosen seat.
472 +
473 +
      No more than one input method must be associated with any seat at any
474 +
      given time.
475 +
    </description>
476 +
477 +
    <request name="get_input_method">
478 +
      <description summary="request an input method object">
479 +
        Request a new input zwp_input_method_v2 object associated with a given
480 +
        seat.
481 +
      </description>
482 +
      <arg name="seat" type="object" interface="wl_seat"/>
483 +
      <arg name="input_method" type="new_id" interface="zwp_input_method_v2"/>
484 +
    </request>
485 +
486 +
    <request name="destroy" type="destructor">
487 +
      <description summary="destroy the input method manager">
488 +
        Destroys the zwp_input_method_manager_v2 object.
489 +
490 +
        The zwp_input_method_v2 objects originating from it remain valid.
491 +
      </description>
492 +
    </request>
493 +
  </interface>
494 +
</protocol>
protocols/virtual-keyboard-unstable-v1.xml added +62 -0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!-- SPDX-License-Identifier: MIT -->
3 +
<protocol name="virtual_keyboard_unstable_v1">
4 +
  <copyright>
5 +
    Copyright © 2008-2011 Kristian Høgsberg
6 +
    Copyright © 2010-2013 Intel Corporation
7 +
    Copyright © 2012-2013 Collabora, Ltd.
8 +
    Copyright © 2018 Purism SPC
9 +
10 +
    Permission is hereby granted, free of charge, to any person obtaining a
11 +
    copy of this software and associated documentation files (the "Software"),
12 +
    to deal in the Software without restriction, including without limitation
13 +
    the rights to use, copy, modify, merge, publish, distribute, sublicense,
14 +
    and/or sell copies of the Software, and to permit persons to whom the
15 +
    Software is furnished to do so, subject to the following conditions:
16 +
17 +
    The above copyright notice and this permission notice shall be included
18 +
    in all copies or substantial portions of the Software.
19 +
20 +
    THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21 +
    IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22 +
    FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23 +
    AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24 +
    LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
25 +
    FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
26 +
    DEALINGS IN THE SOFTWARE.
27 +
  </copyright>
28 +
  <interface name="zwp_virtual_keyboard_v1" version="1">
29 +
    <description summary="virtual keyboard"/>
30 +
    <request name="keymap">
31 +
      <arg name="format" type="uint"/>
32 +
      <arg name="fd" type="fd"/>
33 +
      <arg name="size" type="uint"/>
34 +
    </request>
35 +
    <enum name="error">
36 +
      <entry name="no_keymap" value="0" summary="No keymap was set"/>
37 +
    </enum>
38 +
    <request name="key">
39 +
      <arg name="time" type="uint"/>
40 +
      <arg name="key" type="uint"/>
41 +
      <arg name="state" type="uint"/>
42 +
    </request>
43 +
    <request name="modifiers">
44 +
      <arg name="mods_depressed" type="uint"/>
45 +
      <arg name="mods_latched" type="uint"/>
46 +
      <arg name="mods_locked" type="uint"/>
47 +
      <arg name="group" type="uint"/>
48 +
    </request>
49 +
    <request name="destroy" type="destructor" since="1"/>
50 +
  </interface>
51 +
  <interface name="zwp_virtual_keyboard_manager_v1" version="1">
52 +
    <description summary="virtual keyboard manager"/>
53 +
    <enum name="error">
54 +
      <entry name="unauthorized" value="0"
55 +
        summary="client not authorized to use the interface"/>
56 +
    </enum>
57 +
    <request name="create_virtual_keyboard">
58 +
      <arg name="seat" type="object" interface="wl_seat"/>
59 +
      <arg name="id" type="new_id" interface="zwp_virtual_keyboard_v1"/>
60 +
    </request>
61 +
  </interface>
62 +
</protocol>
protocols/wlr-foreign-toplevel-management-unstable-v1.xml added +270 -0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<protocol name="wlr_foreign_toplevel_management_unstable_v1">
3 +
  <copyright>
4 +
    Copyright © 2018 Ilia Bozhinov
5 +
6 +
    Permission to use, copy, modify, distribute, and sell this
7 +
    software and its documentation for any purpose is hereby granted
8 +
    without fee, provided that the above copyright notice appear in
9 +
    all copies and that both that copyright notice and this permission
10 +
    notice appear in supporting documentation, and that the name of
11 +
    the copyright holders not be used in advertising or publicity
12 +
    pertaining to distribution of the software without specific,
13 +
    written prior permission.  The copyright holders make no
14 +
    representations about the suitability of this software for any
15 +
    purpose.  It is provided "as is" without express or implied
16 +
    warranty.
17 +
18 +
    THE COPYRIGHT HOLDERS DISCLAIM ALL WARRANTIES WITH REGARD TO THIS
19 +
    SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
20 +
    FITNESS, IN NO EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY
21 +
    SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
22 +
    WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
23 +
    AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
24 +
    ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
25 +
    THIS SOFTWARE.
26 +
  </copyright>
27 +
28 +
  <interface name="zwlr_foreign_toplevel_manager_v1" version="3">
29 +
    <description summary="list and control opened apps">
30 +
      The purpose of this protocol is to enable the creation of taskbars
31 +
      and docks by providing them with a list of opened applications and
32 +
      letting them request certain actions on them, like maximizing, etc.
33 +
34 +
      After a client binds the zwlr_foreign_toplevel_manager_v1, each opened
35 +
      toplevel window will be sent via the toplevel event
36 +
    </description>
37 +
38 +
    <event name="toplevel">
39 +
      <description summary="a toplevel has been created">
40 +
        This event is emitted whenever a new toplevel window is created. It
41 +
        is emitted for all toplevels, regardless of the app that has created
42 +
        them.
43 +
44 +
        All initial details of the toplevel(title, app_id, states, etc.) will
45 +
        be sent immediately after this event via the corresponding events in
46 +
        zwlr_foreign_toplevel_handle_v1.
47 +
      </description>
48 +
      <arg name="toplevel" type="new_id" interface="zwlr_foreign_toplevel_handle_v1"/>
49 +
    </event>
50 +
51 +
    <request name="stop">
52 +
      <description summary="stop sending events">
53 +
        Indicates the client no longer wishes to receive events for new toplevels.
54 +
        However the compositor may emit further toplevel_created events, until
55 +
        the finished event is emitted.
56 +
57 +
        The client must not send any more requests after this one.
58 +
      </description>
59 +
    </request>
60 +
61 +
    <event name="finished" type="destructor">
62 +
      <description summary="the compositor has finished with the toplevel manager">
63 +
        This event indicates that the compositor is done sending events to the
64 +
        zwlr_foreign_toplevel_manager_v1. The server will destroy the object
65 +
        immediately after sending this request, so it will become invalid and
66 +
        the client should free any resources associated with it.
67 +
      </description>
68 +
    </event>
69 +
  </interface>
70 +
71 +
  <interface name="zwlr_foreign_toplevel_handle_v1" version="3">
72 +
    <description summary="an opened toplevel">
73 +
      A zwlr_foreign_toplevel_handle_v1 object represents an opened toplevel
74 +
      window. Each app may have multiple opened toplevels.
75 +
76 +
      Each toplevel has a list of outputs it is visible on, conveyed to the
77 +
      client with the output_enter and output_leave events.
78 +
    </description>
79 +
80 +
    <event name="title">
81 +
      <description summary="title change">
82 +
        This event is emitted whenever the title of the toplevel changes.
83 +
      </description>
84 +
      <arg name="title" type="string"/>
85 +
    </event>
86 +
87 +
    <event name="app_id">
88 +
      <description summary="app-id change">
89 +
        This event is emitted whenever the app-id of the toplevel changes.
90 +
      </description>
91 +
      <arg name="app_id" type="string"/>
92 +
    </event>
93 +
94 +
    <event name="output_enter">
95 +
      <description summary="toplevel entered an output">
96 +
        This event is emitted whenever the toplevel becomes visible on
97 +
        the given output. A toplevel may be visible on multiple outputs.
98 +
      </description>
99 +
      <arg name="output" type="object" interface="wl_output"/>
100 +
    </event>
101 +
102 +
    <event name="output_leave">
103 +
      <description summary="toplevel left an output">
104 +
        This event is emitted whenever the toplevel stops being visible on
105 +
        the given output. It is guaranteed that an entered-output event
106 +
        with the same output has been emitted before this event.
107 +
      </description>
108 +
      <arg name="output" type="object" interface="wl_output"/>
109 +
    </event>
110 +
111 +
    <request name="set_maximized">
112 +
      <description summary="requests that the toplevel be maximized">
113 +
        Requests that the toplevel be maximized. If the maximized state actually
114 +
        changes, this will be indicated by the state event.
115 +
      </description>
116 +
    </request>
117 +
118 +
    <request name="unset_maximized">
119 +
      <description summary="requests that the toplevel be unmaximized">
120 +
        Requests that the toplevel be unmaximized. If the maximized state actually
121 +
        changes, this will be indicated by the state event.
122 +
      </description>
123 +
    </request>
124 +
125 +
    <request name="set_minimized">
126 +
      <description summary="requests that the toplevel be minimized">
127 +
        Requests that the toplevel be minimized. If the minimized state actually
128 +
        changes, this will be indicated by the state event.
129 +
      </description>
130 +
    </request>
131 +
132 +
    <request name="unset_minimized">
133 +
      <description summary="requests that the toplevel be unminimized">
134 +
        Requests that the toplevel be unminimized. If the minimized state actually
135 +
        changes, this will be indicated by the state event.
136 +
      </description>
137 +
    </request>
138 +
139 +
    <request name="activate">
140 +
      <description summary="activate the toplevel">
141 +
        Request that this toplevel be activated on the given seat.
142 +
        There is no guarantee the toplevel will be actually activated.
143 +
      </description>
144 +
      <arg name="seat" type="object" interface="wl_seat"/>
145 +
    </request>
146 +
147 +
    <enum name="state">
148 +
      <description summary="types of states on the toplevel">
149 +
        The different states that a toplevel can have. These have the same meaning
150 +
        as the states with the same names defined in xdg-toplevel
151 +
      </description>
152 +
153 +
      <entry name="maximized"  value="0" summary="the toplevel is maximized"/>
154 +
      <entry name="minimized"  value="1" summary="the toplevel is minimized"/>
155 +
      <entry name="activated"  value="2" summary="the toplevel is active"/>
156 +
      <entry name="fullscreen" value="3" summary="the toplevel is fullscreen" since="2"/>
157 +
    </enum>
158 +
159 +
    <event name="state">
160 +
      <description summary="the toplevel state changed">
161 +
        This event is emitted immediately after the zlw_foreign_toplevel_handle_v1
162 +
        is created and each time the toplevel state changes, either because of a
163 +
        compositor action or because of a request in this protocol.
164 +
      </description>
165 +
166 +
      <arg name="state" type="array"/>
167 +
    </event>
168 +
169 +
    <event name="done">
170 +
      <description summary="all information about the toplevel has been sent">
171 +
        This event is sent after all changes in the toplevel state have been
172 +
        sent.
173 +
174 +
        This allows changes to the zwlr_foreign_toplevel_handle_v1 properties
175 +
        to be seen as atomic, even if they happen via multiple events.
176 +
      </description>
177 +
    </event>
178 +
179 +
    <request name="close">
180 +
      <description summary="request that the toplevel be closed">
181 +
        Send a request to the toplevel to close itself. The compositor would
182 +
        typically use a shell-specific method to carry out this request, for
183 +
        example by sending the xdg_toplevel.close event. However, this gives
184 +
        no guarantees the toplevel will actually be destroyed. If and when
185 +
        this happens, the zwlr_foreign_toplevel_handle_v1.closed event will
186 +
        be emitted.
187 +
      </description>
188 +
    </request>
189 +
190 +
    <request name="set_rectangle">
191 +
      <description summary="the rectangle which represents the toplevel">
192 +
        The rectangle of the surface specified in this request corresponds to
193 +
        the place where the app using this protocol represents the given toplevel.
194 +
        It can be used by the compositor as a hint for some operations, e.g
195 +
        minimizing. The client is however not required to set this, in which
196 +
        case the compositor is free to decide some default value.
197 +
198 +
        If the client specifies more than one rectangle, only the last one is
199 +
        considered.
200 +
201 +
        The dimensions are given in surface-local coordinates.
202 +
        Setting width=height=0 removes the already-set rectangle.
203 +
      </description>
204 +
205 +
      <arg name="surface" type="object" interface="wl_surface"/>
206 +
      <arg name="x" type="int"/>
207 +
      <arg name="y" type="int"/>
208 +
      <arg name="width" type="int"/>
209 +
      <arg name="height" type="int"/>
210 +
    </request>
211 +
212 +
    <enum name="error">
213 +
      <entry name="invalid_rectangle" value="0"
214 +
        summary="the provided rectangle is invalid"/>
215 +
    </enum>
216 +
217 +
    <event name="closed">
218 +
      <description summary="this toplevel has been destroyed">
219 +
        This event means the toplevel has been destroyed. It is guaranteed there
220 +
        won't be any more events for this zwlr_foreign_toplevel_handle_v1. The
221 +
        toplevel itself becomes inert so any requests will be ignored except the
222 +
        destroy request.
223 +
      </description>
224 +
    </event>
225 +
226 +
    <request name="destroy" type="destructor">
227 +
      <description summary="destroy the zwlr_foreign_toplevel_handle_v1 object">
228 +
        Destroys the zwlr_foreign_toplevel_handle_v1 object.
229 +
230 +
        This request should be called either when the client does not want to
231 +
        use the toplevel anymore or after the closed event to finalize the
232 +
        destruction of the object.
233 +
      </description>
234 +
    </request>
235 +
236 +
    <!-- Version 2 additions -->
237 +
238 +
    <request name="set_fullscreen" since="2">
239 +
      <description summary="request that the toplevel be fullscreened">
240 +
        Requests that the toplevel be fullscreened on the given output. If the
241 +
        fullscreen state and/or the outputs the toplevel is visible on actually
242 +
        change, this will be indicated by the state and output_enter/leave
243 +
        events.
244 +
245 +
        The output parameter is only a hint to the compositor. Also, if output
246 +
        is NULL, the compositor should decide which output the toplevel will be
247 +
        fullscreened on, if at all.
248 +
      </description>
249 +
      <arg name="output" type="object" interface="wl_output" allow-null="true"/>
250 +
    </request>
251 +
252 +
    <request name="unset_fullscreen" since="2">
253 +
      <description summary="request that the toplevel be unfullscreened">
254 +
        Requests that the toplevel be unfullscreened. If the fullscreen state
255 +
        actually changes, this will be indicated by the state event.
256 +
      </description>
257 +
    </request>
258 +
259 +
    <!-- Version 3 additions -->
260 +
261 +
    <event name="parent" since="3">
262 +
      <description summary="parent change">
263 +
        This event is emitted whenever the parent of the toplevel changes.
264 +
265 +
        No event is emitted when the parent handle is destroyed by the client.
266 +
      </description>
267 +
      <arg name="parent" type="object" interface="zwlr_foreign_toplevel_handle_v1" allow-null="true"/>
268 +
    </event>
269 +
  </interface>
270 +
</protocol>
protocols/wlr-virtual-pointer-unstable-v1.xml added +65 -0
1 +
<?xml version="1.0" encoding="UTF-8"?>
2 +
<!-- SPDX-License-Identifier: MIT -->
3 +
<protocol name="wlr_virtual_pointer_unstable_v1">
4 +
  <copyright>
5 +
    Copyright © 2019 Josef Gajdusek
6 +
    Permission is hereby granted, free of charge, to any person obtaining a
7 +
    copy of this software and associated documentation files, to deal in the
8 +
    Software without restriction, including without limitation the rights to
9 +
    use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10 +
    copies, subject to inclusion of this notice. THE SOFTWARE IS PROVIDED
11 +
    "AS IS", WITHOUT WARRANTY OF ANY KIND.
12 +
  </copyright>
13 +
  <interface name="zwlr_virtual_pointer_v1" version="2">
14 +
    <description summary="virtual pointer"/>
15 +
    <enum name="error">
16 +
      <entry name="invalid_axis" value="0" summary="invalid axis"/>
17 +
      <entry name="invalid_axis_source" value="1" summary="invalid source"/>
18 +
    </enum>
19 +
    <request name="motion">
20 +
      <arg name="time" type="uint"/><arg name="dx" type="fixed"/>
21 +
      <arg name="dy" type="fixed"/>
22 +
    </request>
23 +
    <request name="motion_absolute">
24 +
      <arg name="time" type="uint"/><arg name="x" type="uint"/>
25 +
      <arg name="y" type="uint"/><arg name="x_extent" type="uint"/>
26 +
      <arg name="y_extent" type="uint"/>
27 +
    </request>
28 +
    <request name="button">
29 +
      <arg name="time" type="uint"/><arg name="button" type="uint"/>
30 +
      <arg name="state" type="uint" enum="wl_pointer.button_state"/>
31 +
    </request>
32 +
    <request name="axis">
33 +
      <arg name="time" type="uint"/>
34 +
      <arg name="axis" type="uint" enum="wl_pointer.axis"/>
35 +
      <arg name="value" type="fixed"/>
36 +
    </request>
37 +
    <request name="frame"/>
38 +
    <request name="axis_source">
39 +
      <arg name="axis_source" type="uint" enum="wl_pointer.axis_source"/>
40 +
    </request>
41 +
    <request name="axis_stop">
42 +
      <arg name="time" type="uint"/>
43 +
      <arg name="axis" type="uint" enum="wl_pointer.axis"/>
44 +
    </request>
45 +
    <request name="axis_discrete">
46 +
      <arg name="time" type="uint"/>
47 +
      <arg name="axis" type="uint" enum="wl_pointer.axis"/>
48 +
      <arg name="value" type="fixed"/><arg name="discrete" type="int"/>
49 +
    </request>
50 +
    <request name="destroy" type="destructor" since="1"/>
51 +
  </interface>
52 +
  <interface name="zwlr_virtual_pointer_manager_v1" version="2">
53 +
    <description summary="virtual pointer manager"/>
54 +
    <request name="create_virtual_pointer">
55 +
      <arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
56 +
      <arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
57 +
    </request>
58 +
    <request name="destroy" type="destructor" since="1"/>
59 +
    <request name="create_virtual_pointer_with_output" since="2">
60 +
      <arg name="seat" type="object" interface="wl_seat" allow-null="true"/>
61 +
      <arg name="output" type="object" interface="wl_output" allow-null="true"/>
62 +
      <arg name="id" type="new_id" interface="zwlr_virtual_pointer_v1"/>
63 +
    </request>
64 +
  </interface>
65 +
</protocol>
test/.gitignore added +2 -0
1 +
.build/
2 +
__pycache__/
test/clients.py added +924 -0
1 +
#!/usr/bin/env python3
2 +
3 +
"""Protocol clients used by the Python integration runner."""
4 +
5 +
from __future__ import annotations
6 +
7 +
import ctypes
8 +
import mmap
9 +
import os
10 +
import struct
11 +
import subprocess
12 +
import sys
13 +
import tempfile
14 +
import time
15 +
from pathlib import Path
16 +
17 +
from pywayland.client import Display
18 +
19 +
from protocols.ext_session_lock_v1 import (
20 +
    ExtSessionLockManagerV1,
21 +
)
22 +
from protocols.ext_workspace_v1 import ExtWorkspaceManagerV1
23 +
from protocols.idle_inhibit_unstable_v1 import ZwpIdleInhibitManagerV1
24 +
from protocols.input_method_unstable_v2 import ZwpInputMethodManagerV2
25 +
from protocols.keyboard_shortcuts_inhibit_unstable_v1 import (
26 +
    ZwpKeyboardShortcutsInhibitManagerV1,
27 +
)
28 +
from protocols.pointer_constraints_unstable_v1 import ZwpPointerConstraintsV1
29 +
from protocols.relative_pointer_unstable_v1 import ZwpRelativePointerManagerV1
30 +
from protocols.text_input_unstable_v3 import ZwpTextInputManagerV3
31 +
from protocols.virtual_keyboard_unstable_v1 import ZwpVirtualKeyboardManagerV1
32 +
from protocols.wayland import (
33 +
    WlCompositor,
34 +
    WlKeyboard,
35 +
    WlOutput,
36 +
    WlSeat,
37 +
    WlShm,
38 +
)
39 +
from protocols.wlr_foreign_toplevel_management_unstable_v1 import (
40 +
    ZwlrForeignToplevelManagerV1,
41 +
)
42 +
from protocols.wlr_layer_shell_unstable_v1 import ZwlrLayerShellV1
43 +
from protocols.wlr_output_power_management_unstable_v1 import (
44 +
    ZwlrOutputPowerManagerV1,
45 +
)
46 +
from protocols.wlr_virtual_pointer_unstable_v1 import ZwlrVirtualPointerManagerV1
47 +
from protocols.xdg_shell import XdgPositioner, XdgToplevel, XdgWmBase
48 +
49 +
50 +
class Failure(RuntimeError):
51 +
    """Report a protocol-client failure."""
52 +
53 +
54 +
class Connection:
55 +
    """Bind requested globals on one Wayland connection."""
56 +
57 +
    def __init__(self, *interfaces):
58 +
        self.display = Display()
59 +
        self.display.connect()
60 +
        self.registry = self.display.get_registry()
61 +
        self.interfaces = {interface.name: interface for interface in interfaces}
62 +
        self.globals: dict[str, object] = {}
63 +
        self.registry.dispatcher["global"] = self._global
64 +
        self.display.roundtrip()
65 +
66 +
    def _global(self, registry, name: int, interface: str, version: int) -> None:
67 +
        wanted = self.interfaces.get(interface)
68 +
        if wanted is not None and interface not in self.globals:
69 +
            self.globals[interface] = registry.bind(name, wanted, min(version, wanted.version))
70 +
71 +
    def get(self, interface):
72 +
        """Return a required bound global."""
73 +
74 +
        try:
75 +
            return self.globals[interface.name]
76 +
        except KeyError as error:
77 +
            raise Failure(f"compositor does not advertise {interface.name}") from error
78 +
79 +
    def roundtrips(self, count: int = 1) -> None:
80 +
        """Complete several request/event exchanges."""
81 +
82 +
        for _ in range(count):
83 +
            if self.display.roundtrip() < 0:
84 +
                raise Failure("Wayland roundtrip failed")
85 +
86 +
    def close(self) -> None:
87 +
        """Disconnect from the compositor."""
88 +
89 +
        self.display.disconnect()
90 +
91 +
92 +
def create_buffer(shm, width: int, height: int, color: int):
93 +
    """Create a solid-color shared-memory buffer."""
94 +
95 +
    size = width * height * 4
96 +
    with tempfile.TemporaryFile(dir=os.environ.get("SWM_TEST_DIR")) as stream:
97 +
        stream.truncate(size)
98 +
        pixels = mmap.mmap(stream.fileno(), size)
99 +
        pixels[:] = struct.pack("=I", color) * (width * height)
100 +
        pixels.flush()
101 +
        pool = shm.create_pool(stream.fileno(), size)
102 +
        buffer = pool.create_buffer(0, width, height, width * 4, WlShm.format.argb8888)
103 +
        pool.destroy()
104 +
    buffer.dispatcher["release"] = lambda proxy: proxy.destroy()
105 +
    return buffer
106 +
107 +
108 +
class Window:
109 +
    """Own one mapped XDG toplevel and report configure state."""
110 +
111 +
    def __init__(
112 +
        self,
113 +
        connection: Connection,
114 +
        title: str,
115 +
        color: int,
116 +
        parent=None,
117 +
        fullscreen: bool = False,
118 +
        report: Path | None = None,
119 +
    ):
120 +
        self.connection = connection
121 +
        self.compositor = connection.get(WlCompositor)
122 +
        self.shm = connection.get(WlShm)
123 +
        self.wm_base = connection.get(XdgWmBase)
124 +
        self.title = title
125 +
        self.color = color
126 +
        self.width = 320
127 +
        self.height = 200
128 +
        self.fullscreen = False
129 +
        self.running = True
130 +
        self.report = report
131 +
        self.wm_base.dispatcher["ping"] = lambda proxy, serial: proxy.pong(serial)
132 +
        self.surface = self.compositor.create_surface()
133 +
        self.xdg_surface = self.wm_base.get_xdg_surface(self.surface)
134 +
        self.xdg_surface.dispatcher["configure"] = self._surface_configure
135 +
        self.toplevel = self.xdg_surface.get_toplevel()
136 +
        self.toplevel.dispatcher["configure"] = self._toplevel_configure
137 +
        self.toplevel.dispatcher["close"] = lambda proxy: setattr(self, "running", False)
138 +
        self.toplevel.set_title(title)
139 +
        self.toplevel.set_app_id("swm-test")
140 +
        if parent is not None:
141 +
            self.toplevel.set_parent(parent)
142 +
        if fullscreen:
143 +
            self.toplevel.set_fullscreen(None)
144 +
        self.surface.commit()
145 +
146 +
    def _toplevel_configure(self, proxy, width: int, height: int, states) -> None:
147 +
        if width > 0:
148 +
            self.width = width
149 +
        if height > 0:
150 +
            self.height = height
151 +
        self.fullscreen = int(XdgToplevel.state.fullscreen) in list(states)
152 +
        self._write_report()
153 +
154 +
    def _surface_configure(self, proxy, serial: int) -> None:
155 +
        proxy.ack_configure(serial)
156 +
        self.draw()
157 +
158 +
    def _write_report(self) -> None:
159 +
        if self.report:
160 +
            self.report.write_text(f"{self.width} {self.height} {int(self.fullscreen)}\n")
161 +
162 +
    def draw(self) -> None:
163 +
        """Attach a fresh buffer and commit the surface."""
164 +
165 +
        buffer = create_buffer(self.shm, self.width, self.height, self.color)
166 +
        self.surface.attach(buffer, 0, 0)
167 +
        self.surface.damage_buffer(0, 0, self.width, self.height)
168 +
        self.surface.commit()
169 +
        self._write_report()
170 +
171 +
    def unmap(self) -> None:
172 +
        """Detach the surface buffer."""
173 +
174 +
        self.surface.attach(None, 0, 0)
175 +
        self.surface.commit()
176 +
177 +
    def destroy(self) -> None:
178 +
        """Destroy the complete toplevel role."""
179 +
180 +
        self.toplevel.destroy()
181 +
        self.xdg_surface.destroy()
182 +
        self.surface.destroy()
183 +
184 +
185 +
def xdg_client(arguments: list[str]) -> None:
186 +
    """Run one persistent XDG window."""
187 +
188 +
    color = int(arguments[0], 16) if arguments else 0xFF336699
189 +
    title = arguments[1] if len(arguments) > 1 else "swm-test"
190 +
    report = Path(os.environ["SWM_CLIENT_REPORT"]) if os.environ.get("SWM_CLIENT_REPORT") else None
191 +
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
192 +
    window = Window(
193 +
        connection,
194 +
        title,
195 +
        color,
196 +
        fullscreen="fullscreen" in arguments[2:],
197 +
        report=report,
198 +
    )
199 +
    while window.running:
200 +
        connection.display.dispatch(block=True)
201 +
    window.destroy()
202 +
    connection.close()
203 +
204 +
205 +
def xdg_lifecycle(arguments: list[str]) -> None:
206 +
    """Repeatedly map and unmap one XDG toplevel."""
207 +
208 +
    count = int(arguments[0]) if arguments else 8
209 +
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
210 +
    window = Window(connection, "swm-xdg-lifecycle", 0xFF224466)
211 +
    connection.roundtrips(3)
212 +
    for index in range(count):
213 +
        window.unmap()
214 +
        connection.roundtrips(2)
215 +
        window.color += 0x00050311
216 +
        window.surface.commit()
217 +
        connection.roundtrips(2)
218 +
    window.destroy()
219 +
    connection.close()
220 +
221 +
222 +
def transient(arguments: list[str]) -> None:
223 +
    """Exercise popup, parented, and fullscreen XDG surfaces."""
224 +
225 +
    count = int(arguments[0]) if arguments else 4
226 +
    connection = Connection(WlCompositor, WlShm, XdgWmBase)
227 +
    parent = Window(connection, "swm-child-parent", 0xFF335577)
228 +
    fullscreen = Window(connection, "swm-fullscreen", 0xFF224466, fullscreen=True)
229 +
    connection.roundtrips(4)
230 +
    if not fullscreen.fullscreen:
231 +
        raise Failure("fullscreen window was not configured fullscreen")
232 +
233 +
    positioner = connection.get(XdgWmBase).create_positioner()
234 +
    positioner.set_size(120, 60)
235 +
    positioner.set_anchor_rect(30, 20, 10, 10)
236 +
    positioner.set_anchor(XdgPositioner.anchor.bottom_right)
237 +
    positioner.set_gravity(XdgPositioner.gravity.bottom_right)
238 +
    positioner.set_constraint_adjustment(
239 +
        XdgPositioner.constraint_adjustment.slide_x
240 +
        | XdgPositioner.constraint_adjustment.slide_y
241 +
    )
242 +
    popup_surface = connection.get(WlCompositor).create_surface()
243 +
    popup_xdg = connection.get(XdgWmBase).get_xdg_surface(popup_surface)
244 +
    popup_configured = []
245 +
246 +
    def configure(proxy, serial) -> None:
247 +
        proxy.ack_configure(serial)
248 +
        buffer = create_buffer(connection.get(WlShm), 120, 60, 0xFF557733)
249 +
        popup_surface.attach(buffer, 0, 0)
250 +
        popup_surface.damage_buffer(0, 0, 120, 60)
251 +
        popup_surface.commit()
252 +
        popup_configured.append(True)
253 +
254 +
    popup_xdg.dispatcher["configure"] = configure
255 +
    popup = popup_xdg.get_popup(parent.xdg_surface, positioner)
256 +
    popup.dispatcher["configure"] = lambda proxy, x, y, width, height: None
257 +
    popup.dispatcher["popup_done"] = lambda proxy: None
258 +
    popup.dispatcher["repositioned"] = lambda proxy, token: None
259 +
    positioner.destroy()
260 +
    popup_surface.commit()
261 +
    connection.roundtrips(4)
262 +
    if not popup_configured:
263 +
        raise Failure("transient popup was not configured")
264 +
    popup.destroy()
265 +
    popup_xdg.destroy()
266 +
    popup_surface.destroy()
267 +
    connection.roundtrips()
268 +
269 +
    for index in range(count):
270 +
        child = Window(
271 +
            connection,
272 +
            f"swm-child-{index}",
273 +
            0xFF773355 + index * 0x0003070D,
274 +
            parent=parent.toplevel,
275 +
        )
276 +
        connection.roundtrips(3)
277 +
        if not fullscreen.fullscreen:
278 +
            raise Failure("transient child stole fullscreen")
279 +
        child.destroy()
280 +
        connection.roundtrips()
281 +
    fullscreen.destroy()
282 +
    parent.destroy()
283 +
    connection.close()
284 +
285 +
286 +
def keymap_text() -> tuple[bytes, dict[str, int]]:
287 +
    """Build a default XKB keymap and return modifier indices."""
288 +
289 +
    xkb = ctypes.CDLL("libxkbcommon.so.0")
290 +
    xkb.xkb_context_new.argtypes = [ctypes.c_int]
291 +
    xkb.xkb_context_new.restype = ctypes.c_void_p
292 +
    xkb.xkb_keymap_new_from_names.argtypes = [ctypes.c_void_p, ctypes.c_void_p, ctypes.c_int]
293 +
    xkb.xkb_keymap_new_from_names.restype = ctypes.c_void_p
294 +
    xkb.xkb_keymap_get_as_string.argtypes = [ctypes.c_void_p, ctypes.c_int]
295 +
    xkb.xkb_keymap_get_as_string.restype = ctypes.c_void_p
296 +
    xkb.xkb_keymap_mod_get_index.argtypes = [ctypes.c_void_p, ctypes.c_char_p]
297 +
    xkb.xkb_keymap_mod_get_index.restype = ctypes.c_uint
298 +
    xkb.xkb_keymap_unref.argtypes = [ctypes.c_void_p]
299 +
    xkb.xkb_context_unref.argtypes = [ctypes.c_void_p]
300 +
    context = xkb.xkb_context_new(0)
301 +
    keymap = xkb.xkb_keymap_new_from_names(context, None, 0)
302 +
    pointer = xkb.xkb_keymap_get_as_string(keymap, 1)
303 +
    text = ctypes.string_at(pointer)
304 +
    ctypes.CDLL(None).free(ctypes.c_void_p(pointer))
305 +
    indices = {
306 +
        "shift": xkb.xkb_keymap_mod_get_index(keymap, b"Shift"),
307 +
        "ctrl": xkb.xkb_keymap_mod_get_index(keymap, b"Control"),
308 +
        "alt": xkb.xkb_keymap_mod_get_index(keymap, b"Mod1"),
309 +
        "logo": xkb.xkb_keymap_mod_get_index(keymap, b"Mod4"),
310 +
    }
311 +
    xkb.xkb_keymap_unref(keymap)
312 +
    xkb.xkb_context_unref(context)
313 +
    return text, indices
314 +
315 +
316 +
def keyboard(arguments: list[str]) -> None:
317 +
    """Send a key and modifiers through virtual-keyboard-v1."""
318 +
319 +
    if len(arguments) != 2:
320 +
        raise Failure("keyboard requires KEYCODE|none MODIFIERS")
321 +
    key = None if arguments[0] == "none" else int(arguments[0], 0)
322 +
    requested = int(arguments[1], 0)
323 +
    connection = Connection(WlSeat, ZwpVirtualKeyboardManagerV1)
324 +
    seat = connection.get(WlSeat)
325 +
    manager = connection.get(ZwpVirtualKeyboardManagerV1)
326 +
    virtual = manager.create_virtual_keyboard(seat)
327 +
    text, indices = keymap_text()
328 +
    with tempfile.TemporaryFile(dir=os.environ.get("SWM_TEST_DIR")) as stream:
329 +
        stream.write(text + b"\0")
330 +
        stream.flush()
331 +
        virtual.keymap(WlKeyboard.keymap_format.xkb_v1, stream.fileno(), len(text) + 1)
332 +
    modifiers = 0
333 +
    modifier_keys = (
334 +
        (1, "shift", 42),
335 +
        (4, "ctrl", 29),
336 +
        (8, "alt", 56),
337 +
        (64, "logo", 125),
338 +
    )
339 +
    for flag, name, code in modifier_keys:
340 +
        if requested & flag:
341 +
            modifiers |= 1 << indices[name]
342 +
            virtual.key(0, code, WlKeyboard.key_state.pressed)
343 +
    virtual.modifiers(modifiers, 0, 0, 0)
344 +
    if key is not None:
345 +
        virtual.key(1, key, WlKeyboard.key_state.pressed)
346 +
        virtual.key(2, key, WlKeyboard.key_state.released)
347 +
    connection.roundtrips()
348 +
    ready = os.environ.get("SWM_KEYBOARD_READY")
349 +
    if ready:
350 +
        Path(ready).write_text("ready\n")
351 +
    release = os.environ.get("SWM_KEYBOARD_RELEASE")
352 +
    if release:
353 +
        deadline = time.monotonic() + 2
354 +
        while not Path(release).exists():
355 +
            if time.monotonic() >= deadline:
356 +
                raise Failure("timed out waiting to release virtual keyboard")
357 +
            time.sleep(0.002)
358 +
    for flag, _, code in modifier_keys:
359 +
        if requested & flag:
360 +
            virtual.key(3, code, WlKeyboard.key_state.released)
361 +
    virtual.modifiers(0, 0, 0, 0)
362 +
    virtual.destroy()
363 +
    connection.roundtrips()
364 +
    connection.close()
365 +
366 +
367 +
def pointer(arguments: list[str]) -> None:
368 +
    """Move and click through virtual-pointer-v1."""
369 +
370 +
    x = int(arguments[0])
371 +
    button = 0x111 if len(arguments) > 1 and arguments[1] == "right" else 0x110
372 +
    connection = Connection(WlSeat, ZwlrVirtualPointerManagerV1)
373 +
    manager = connection.get(ZwlrVirtualPointerManagerV1)
374 +
    virtual = manager.create_virtual_pointer(connection.get(WlSeat))
375 +
    virtual.motion_absolute(0, x, 500, 1000, 1000)
376 +
    virtual.frame()
377 +
    virtual.button(1, button, 1)
378 +
    virtual.motion(2, 25.0, 20.0)
379 +
    virtual.frame()
380 +
    virtual.button(3, button, 0)
381 +
    virtual.axis_source(0)
382 +
    virtual.axis_discrete(3, 0, 1.0, 1)
383 +
    virtual.axis_stop(4, 0)
384 +
    virtual.frame()
385 +
    connection.roundtrips()
386 +
    virtual.destroy()
387 +
    connection.close()
388 +
389 +
390 +
def foreign(arguments: list[str]) -> None:
391 +
    """Apply one foreign-toplevel action to a named window."""
392 +
393 +
    title, action = arguments
394 +
    connection = Connection(WlSeat, ZwlrForeignToplevelManagerV1)
395 +
    seat = connection.get(WlSeat)
396 +
    manager = connection.get(ZwlrForeignToplevelManagerV1)
397 +
    acted = False
398 +
    observed = False
399 +
400 +
    def toplevel(proxy, handle):
401 +
        nonlocal acted, observed
402 +
        state = {"title": "", "fullscreen": False}
403 +
        fullscreen_state = int(handle.interface.state.fullscreen)
404 +
        handle.dispatcher["title"] = lambda p, value: state.update(title=value)
405 +
406 +
        def states(p, values):
407 +
            nonlocal acted, observed
408 +
            state["fullscreen"] = fullscreen_state in list(values)
409 +
            if state["title"] != title:
410 +
                return
411 +
            if not acted:
412 +
                if action == "activate":
413 +
                    handle.activate(seat)
414 +
                elif action == "fullscreen":
415 +
                    handle.set_fullscreen(None)
416 +
                elif action == "unfullscreen":
417 +
                    handle.unset_fullscreen()
418 +
                elif action == "close":
419 +
                    handle.close()
420 +
                else:
421 +
                    raise Failure(f"unknown foreign action: {action}")
422 +
                acted = True
423 +
            observed = (
424 +
                action == "close"
425 +
                or action == "fullscreen" and state["fullscreen"]
426 +
                or action in {"activate", "unfullscreen"} and not state["fullscreen"]
427 +
            )
428 +
429 +
        handle.dispatcher["state"] = states
430 +
        handle.dispatcher["done"] = lambda p: states(
431 +
            p, [fullscreen_state] if state["fullscreen"] else []
432 +
        )
433 +
        handle.dispatcher["closed"] = lambda p: None
434 +
435 +
    manager.dispatcher["toplevel"] = toplevel
436 +
    for _ in range(20):
437 +
        connection.roundtrips()
438 +
        if acted and observed:
439 +
            break
440 +
    if not acted or not observed:
441 +
        raise Failure(f"foreign-toplevel action {action} was not observed")
442 +
    if action == "close":
443 +
        connection.roundtrips(2)
444 +
    connection.close()
445 +
446 +
447 +
def workspace(arguments: list[str]) -> None:
448 +
    """Activate a named workspace through ext-workspace-v1."""
449 +
450 +
    if len(arguments) != 1:
451 +
        raise Failure("workspace requires a workspace name")
452 +
    target = arguments[0]
453 +
    connection = Connection(ExtWorkspaceManagerV1)
454 +
    manager = connection.get(ExtWorkspaceManagerV1)
455 +
    workspaces: dict[str, object] = {}
456 +
457 +
    def announced(proxy, handle):
458 +
        handle.dispatcher["name"] = lambda p, name: workspaces.update({name: handle})
459 +
        handle.dispatcher["removed"] = lambda p: None
460 +
461 +
    manager.dispatcher["workspace_group"] = lambda proxy, group: None
462 +
    manager.dispatcher["workspace"] = announced
463 +
    manager.dispatcher["done"] = lambda proxy: None
464 +
    manager.dispatcher["finished"] = lambda proxy: None
465 +
    connection.roundtrips(2)
466 +
    if target not in workspaces:
467 +
        raise Failure(f"workspace {target!r} was not advertised")
468 +
    workspaces[target].activate()
469 +
    manager.commit()
470 +
    connection.roundtrips(2)
471 +
    connection.close()
472 +
473 +
474 +
def output_power(arguments: list[str]) -> None:
475 +
    """Turn one output off and back on through output-power-v1."""
476 +
477 +
    connection = Connection(WlOutput, ZwlrOutputPowerManagerV1)
478 +
    power = connection.get(ZwlrOutputPowerManagerV1).get_output_power(connection.get(WlOutput))
479 +
    modes: list[int] = []
480 +
    failed: list[bool] = []
481 +
    power.dispatcher["mode"] = lambda proxy, mode: modes.append(mode)
482 +
    power.dispatcher["failed"] = lambda proxy: failed.append(True)
483 +
    connection.roundtrips()
484 +
    if not modes or modes[-1] != 1:
485 +
        raise Failure("output did not begin powered on")
486 +
    power.set_mode(0)
487 +
    connection.roundtrips(2)
488 +
    if modes[-1] != 0:
489 +
        raise Failure("output did not power off")
490 +
    power.set_mode(1)
491 +
    connection.roundtrips(2)
492 +
    if modes[-1] != 1 or failed:
493 +
        raise Failure("output did not power back on")
494 +
    power.destroy()
495 +
    connection.close()
496 +
497 +
498 +
def output_management(arguments: list[str]) -> None:
499 +
    """Exercise output-management through the installed reference client."""
500 +
501 +
    command = ["wlr-randr"]
502 +
    if arguments and arguments[0] in {"disable-first", "disable-second"}:
503 +
        output = "HEADLESS-1" if arguments[0] == "disable-first" else "HEADLESS-2"
504 +
        command += ["--output", output, "--off"]
505 +
    else:
506 +
        command += ["--output", "HEADLESS-1", "--on"]
507 +
        subprocess.run([*command, "--dryrun"], check=True)
508 +
    subprocess.run(command, check=True)
509 +
510 +
511 +
def layer(arguments: list[str]) -> None:
512 +
    """Create, configure, map, and destroy layer-shell surfaces."""
513 +
514 +
    count = int(arguments[0]) if arguments else 2
515 +
    connection = Connection(WlCompositor, WlShm, ZwlrLayerShellV1)
516 +
    compositor = connection.get(WlCompositor)
517 +
    shm = connection.get(WlShm)
518 +
    shell = connection.get(ZwlrLayerShellV1)
519 +
    layers = []
520 +
    for index in range(count):
521 +
        surface = compositor.create_surface()
522 +
        role = shell.get_layer_surface(
523 +
            surface,
524 +
            None,
525 +
            ZwlrLayerShellV1.layer.top,
526 +
            f"swm-layer-{index}",
527 +
        )
528 +
        configured = []
529 +
530 +
        def configure(
531 +
            proxy,
532 +
            serial,
533 +
            width,
534 +
            height,
535 +
            surface=surface,
536 +
            configured=configured,
537 +
            index=index,
538 +
        ):
539 +
            proxy.ack_configure(serial)
540 +
            width = width or 80
541 +
            height = height or 40
542 +
            surface.attach(create_buffer(shm, width, height, 0xCC112233 + index), 0, 0)
543 +
            surface.damage_buffer(0, 0, width, height)
544 +
            surface.commit()
545 +
            configured.append(True)
546 +
547 +
        role.dispatcher["configure"] = configure
548 +
        role.dispatcher["closed"] = lambda proxy: None
549 +
        role.set_size(80 + index, 40 + index)
550 +
        role.set_anchor(1 << (index % 4))
551 +
        surface.commit()
552 +
        layers.append((surface, role, configured))
553 +
    connection.roundtrips(3)
554 +
    if not all(configured for _, _, configured in layers):
555 +
        raise Failure("layer surface was not configured")
556 +
    for surface, role, _ in reversed(layers):
557 +
        role.destroy()
558 +
        surface.destroy()
559 +
    connection.roundtrips()
560 +
    connection.close()
561 +
562 +
563 +
def session_lock(arguments: list[str]) -> None:
564 +
    """Create and release session locks."""
565 +
566 +
    count = int(arguments[0]) if arguments else 2
567 +
    connection = Connection(WlCompositor, WlShm, WlOutput, ExtSessionLockManagerV1)
568 +
    compositor = connection.get(WlCompositor)
569 +
    shm = connection.get(WlShm)
570 +
    output = connection.get(WlOutput)
571 +
    manager = connection.get(ExtSessionLockManagerV1)
572 +
    for index in range(count):
573 +
        lock = manager.lock()
574 +
        locked: list[bool] = []
575 +
        finished: list[bool] = []
576 +
        lock.dispatcher["locked"] = lambda proxy: locked.append(True)
577 +
        lock.dispatcher["finished"] = lambda proxy: finished.append(True)
578 +
        surface = compositor.create_surface()
579 +
        role = lock.get_lock_surface(surface, output)
580 +
        configured: list[bool] = []
581 +
582 +
        def configure(proxy, serial, width, height):
583 +
            proxy.ack_configure(serial)
584 +
            surface.attach(create_buffer(shm, width, height, 0xFF101010 + index), 0, 0)
585 +
            surface.damage_buffer(0, 0, width, height)
586 +
            surface.commit()
587 +
            configured.append(True)
588 +
589 +
        role.dispatcher["configure"] = configure
590 +
        connection.roundtrips(3)
591 +
        if not finished and (not locked or not configured):
592 +
            raise Failure("session lock was not configured")
593 +
        role.destroy()
594 +
        surface.destroy()
595 +
        if locked:
596 +
            lock.unlock_and_destroy()
597 +
        else:
598 +
            lock.destroy()
599 +
        connection.roundtrips()
600 +
    connection.close()
601 +
602 +
603 +
def text_input(arguments: list[str]) -> None:
604 +
    """Exercise text input, input methods, and input inhibitors together."""
605 +
606 +
    connection = Connection(
607 +
        WlCompositor,
608 +
        WlShm,
609 +
        WlSeat,
610 +
        XdgWmBase,
611 +
        ZwpTextInputManagerV3,
612 +
        ZwpInputMethodManagerV2,
613 +
        ZwpKeyboardShortcutsInhibitManagerV1,
614 +
        ZwpPointerConstraintsV1,
615 +
        ZwpRelativePointerManagerV1,
616 +
        ZwpIdleInhibitManagerV1,
617 +
        ZwlrVirtualPointerManagerV1,
618 +
    )
619 +
    seat = connection.get(WlSeat)
620 +
    pointer = seat.get_pointer()
621 +
    for event in ("enter", "leave", "motion", "button", "axis"):
622 +
        pointer.dispatcher[event] = lambda proxy, *values: None
623 +
624 +
    text = connection.get(ZwpTextInputManagerV3).get_text_input(seat)
625 +
    method = connection.get(ZwpInputMethodManagerV2).get_input_method(seat)
626 +
    state = {
627 +
        "activated": False,
628 +
        "surrounding": False,
629 +
        "cause": False,
630 +
        "content": False,
631 +
        "preedit": False,
632 +
        "commit": False,
633 +
        "delete": False,
634 +
        "done": False,
635 +
        "rectangle": False,
636 +
        "shortcuts": False,
637 +
        "locked": False,
638 +
        "relative": False,
639 +
        "serial": 0,
640 +
    }
641 +
642 +
    def text_enter(proxy, surface) -> None:
643 +
        if surface != window.surface:
644 +
            return
645 +
        text.enable()
646 +
        text.set_surrounding_text("before after", 6, 6)
647 +
        text.set_text_change_cause(1)
648 +
        text.set_content_type(1, 0)
649 +
        text.set_cursor_rectangle(7, 11, 3, 15)
650 +
        text.commit()
651 +
652 +
    def method_done(proxy) -> None:
653 +
        state["serial"] += 1
654 +
        if not all(state[name] for name in ("activated", "surrounding", "cause", "content")):
655 +
            return
656 +
        method.set_preedit_string("preedit", 2, 4)
657 +
        method.commit_string("committed")
658 +
        method.delete_surrounding_text(2, 3)
659 +
        method.commit(state["serial"])
660 +
661 +
    text.dispatcher["enter"] = text_enter
662 +
    text.dispatcher["leave"] = lambda proxy, surface: None
663 +
    text.dispatcher["preedit_string"] = lambda proxy, value, begin, end: state.update(
664 +
        preedit=value == "preedit" and begin == 2 and end == 4
665 +
    )
666 +
    text.dispatcher["commit_string"] = lambda proxy, value: state.update(
667 +
        commit=value == "committed"
668 +
    )
669 +
    text.dispatcher["delete_surrounding_text"] = lambda proxy, before, after: state.update(
670 +
        delete=before == 2 and after == 3
671 +
    )
672 +
    text.dispatcher["done"] = lambda proxy, serial: state.update(done=True)
673 +
674 +
    popup_surface = connection.get(WlCompositor).create_surface()
675 +
    popup = method.get_input_popup_surface(popup_surface)
676 +
    popup.dispatcher["text_input_rectangle"] = lambda proxy, x, y, width, height: state.update(
677 +
        rectangle=(x, y, width, height) == (0, -15, 3, 15)
678 +
    )
679 +
680 +
    def activate(proxy) -> None:
681 +
        state["activated"] = True
682 +
        buffer = create_buffer(connection.get(WlShm), 32, 16, 0xFF8855AA)
683 +
        popup_surface.attach(buffer, 0, 0)
684 +
        popup_surface.damage_buffer(0, 0, 32, 16)
685 +
        popup_surface.commit()
686 +
687 +
    method.dispatcher["activate"] = activate
688 +
    method.dispatcher["deactivate"] = lambda proxy: None
689 +
    method.dispatcher["surrounding_text"] = lambda proxy, value, cursor, anchor: state.update(
690 +
        surrounding=value == "before after" and cursor == 6 and anchor == 6
691 +
    )
692 +
    method.dispatcher["text_change_cause"] = lambda proxy, cause: state.update(cause=cause == 1)
693 +
    method.dispatcher["content_type"] = lambda proxy, hint, purpose: state.update(
694 +
        content=hint == 1 and purpose == 0
695 +
    )
696 +
    method.dispatcher["done"] = method_done
697 +
    method.dispatcher["unavailable"] = lambda proxy: (_ for _ in ()).throw(
698 +
        Failure("input method is unavailable")
699 +
    )
700 +
701 +
    window = Window(connection, "text-input", 0xFF556677)
702 +
    idle = connection.get(ZwpIdleInhibitManagerV1).create_inhibitor(window.surface)
703 +
    shortcuts = connection.get(ZwpKeyboardShortcutsInhibitManagerV1).inhibit_shortcuts(
704 +
        window.surface, seat
705 +
    )
706 +
    shortcuts.dispatcher["active"] = lambda proxy: state.update(shortcuts=True)
707 +
    shortcuts.dispatcher["inactive"] = lambda proxy: state.update(shortcuts=False)
708 +
    locked = connection.get(ZwpPointerConstraintsV1).lock_pointer(
709 +
        window.surface, pointer, None, 2
710 +
    )
711 +
    locked.dispatcher["locked"] = lambda proxy: state.update(locked=True)
712 +
    locked.dispatcher["unlocked"] = lambda proxy: state.update(locked=False)
713 +
    relative = connection.get(ZwpRelativePointerManagerV1).get_relative_pointer(pointer)
714 +
    relative.dispatcher["relative_motion"] = lambda proxy, hi, lo, dx, dy, ux, uy: state.update(
715 +
        relative=dx != 0
716 +
    )
717 +
    virtual = connection.get(ZwlrVirtualPointerManagerV1).create_virtual_pointer(seat)
718 +
719 +
    for index in range(100):
720 +
        virtual.motion_absolute(0, index % 10 * 100 + 50, index // 10 * 100 + 50, 1000, 1000)
721 +
        virtual.frame()
722 +
        connection.roundtrips()
723 +
        if state["locked"]:
724 +
            break
725 +
    virtual.motion(10, 5.0, 0.0)
726 +
    virtual.frame()
727 +
    connection.roundtrips(3)
728 +
729 +
    required = (
730 +
        "preedit",
731 +
        "commit",
732 +
        "delete",
733 +
        "done",
734 +
        "rectangle",
735 +
        "shortcuts",
736 +
        "locked",
737 +
        "relative",
738 +
    )
739 +
    missing = [name for name in required if not state[name]]
740 +
    if missing:
741 +
        raise Failure(f"text-input events not observed: {', '.join(missing)}")
742 +
743 +
    text.disable()
744 +
    text.commit()
745 +
    connection.roundtrips(2)
746 +
    window.destroy()
747 +
    virtual.destroy()
748 +
    relative.destroy()
749 +
    locked.destroy()
750 +
    shortcuts.destroy()
751 +
    idle.destroy()
752 +
    popup.destroy()
753 +
    popup_surface.destroy()
754 +
    text.destroy()
755 +
    method.destroy()
756 +
    connection.close()
757 +
758 +
759 +
def x11(arguments: list[str]) -> None:
760 +
    """Exercise managed, configured, fullscreen, and unmanaged X11 windows."""
761 +
762 +
    import xcffib
763 +
    import xcffib.xproto
764 +
765 +
    connection = xcffib.connect()
766 +
    setup = connection.get_setup()
767 +
    screen = setup.roots[connection.pref_screen]
768 +
    window = connection.generate_id()
769 +
    mask = xcffib.xproto.CW.BackPixel | xcffib.xproto.CW.EventMask
770 +
    values = [screen.white_pixel, xcffib.xproto.EventMask.StructureNotify]
771 +
    connection.core.CreateWindow(
772 +
        screen.root_depth,
773 +
        window,
774 +
        screen.root,
775 +
        30,
776 +
        30,
777 +
        220,
778 +
        140,
779 +
        0,
780 +
        xcffib.xproto.WindowClass.InputOutput,
781 +
        screen.root_visual,
782 +
        mask,
783 +
        values,
784 +
    )
785 +
    wm_name = connection.core.InternAtom(False, len("WM_NAME"), "WM_NAME").reply().atom
786 +
    string = connection.core.InternAtom(False, len("STRING"), "STRING").reply().atom
787 +
    title = b"swm-xwayland-test"
788 +
    connection.core.ChangeProperty(
789 +
        xcffib.xproto.PropMode.Replace,
790 +
        window,
791 +
        wm_name,
792 +
        string,
793 +
        8,
794 +
        len(title),
795 +
        title,
796 +
    )
797 +
    wm_class = connection.core.InternAtom(False, len("WM_CLASS"), "WM_CLASS").reply().atom
798 +
    class_name = b"x11-test\0X11-Test\0"
799 +
    connection.core.ChangeProperty(
800 +
        xcffib.xproto.PropMode.Replace,
801 +
        window,
802 +
        wm_class,
803 +
        string,
804 +
        8,
805 +
        len(class_name),
806 +
        class_name,
807 +
    )
808 +
    connection.core.MapWindow(window)
809 +
    configure = (
810 +
        xcffib.xproto.ConfigWindow.X
811 +
        | xcffib.xproto.ConfigWindow.Y
812 +
        | xcffib.xproto.ConfigWindow.Width
813 +
        | xcffib.xproto.ConfigWindow.Height
814 +
    )
815 +
    connection.core.ConfigureWindow(window, configure, [40, 50, 360, 240])
816 +
    connection.flush()
817 +
    for _ in range(100):
818 +
        event = connection.poll_for_event()
819 +
        if isinstance(event, xcffib.xproto.MapNotifyEvent):
820 +
            break
821 +
        time.sleep(0.01)
822 +
    else:
823 +
        raise Failure("XWayland window was not mapped")
824 +
825 +
    connection.core.ConfigureWindow(window, configure, [70, 80, 400, 260])
826 +
    connection.core.GetInputFocus().reply()
827 +
828 +
    unmanaged = connection.generate_id()
829 +
    unmanaged_mask = xcffib.xproto.CW.OverrideRedirect | xcffib.xproto.CW.EventMask
830 +
    connection.core.CreateWindow(
831 +
        screen.root_depth,
832 +
        unmanaged,
833 +
        screen.root,
834 +
        15,
835 +
        25,
836 +
        180,
837 +
        100,
838 +
        0,
839 +
        xcffib.xproto.WindowClass.InputOutput,
840 +
        screen.root_visual,
841 +
        unmanaged_mask,
842 +
        [1, xcffib.xproto.EventMask.StructureNotify],
843 +
    )
844 +
    unmanaged_title = b"x11-unmanaged"
845 +
    connection.core.ChangeProperty(
846 +
        xcffib.xproto.PropMode.Replace,
847 +
        unmanaged,
848 +
        wm_name,
849 +
        string,
850 +
        8,
851 +
        len(unmanaged_title),
852 +
        unmanaged_title,
853 +
    )
854 +
    connection.core.MapWindow(unmanaged)
855 +
    connection.flush()
856 +
    for _ in range(100):
857 +
        attributes = connection.core.GetWindowAttributes(unmanaged).reply()
858 +
        if attributes.map_state == xcffib.xproto.MapState.Viewable:
859 +
            break
860 +
        time.sleep(0.01)
861 +
    else:
862 +
        raise Failure("override-redirect XWayland window was not mapped")
863 +
    connection.core.ConfigureWindow(unmanaged, configure, [30, 40, 220, 130])
864 +
    connection.core.GetInputFocus().reply()
865 +
866 +
    net_state = connection.core.InternAtom(
867 +
        False, len("_NET_WM_STATE"), "_NET_WM_STATE"
868 +
    ).reply().atom
869 +
    net_fullscreen = connection.core.InternAtom(
870 +
        False, len("_NET_WM_STATE_FULLSCREEN"), "_NET_WM_STATE_FULLSCREEN"
871 +
    ).reply().atom
872 +
    message = xcffib.xproto.ClientMessageEvent.synthetic(
873 +
        32,
874 +
        window,
875 +
        net_state,
876 +
        ([1, net_fullscreen, 0, 1, 0], "=5I"),
877 +
    )
878 +
    event_mask = (
879 +
        xcffib.xproto.EventMask.SubstructureRedirect
880 +
        | xcffib.xproto.EventMask.SubstructureNotify
881 +
    )
882 +
    connection.core.SendEvent(False, screen.root, event_mask, message.pack())
883 +
    connection.flush()
884 +
    connection.core.GetInputFocus().reply()
885 +
886 +
    connection.core.UnmapWindow(window)
887 +
    connection.core.UnmapWindow(unmanaged)
888 +
    connection.core.DestroyWindow(window)
889 +
    connection.core.DestroyWindow(unmanaged)
890 +
    connection.flush()
891 +
    connection.disconnect()
892 +
893 +
894 +
ROLES = {
895 +
    "xdg": xdg_client,
896 +
    "xdg-lifecycle": xdg_lifecycle,
897 +
    "transient": transient,
898 +
    "keyboard": keyboard,
899 +
    "pointer": pointer,
900 +
    "foreign": foreign,
901 +
    "workspace": workspace,
902 +
    "output-power": output_power,
903 +
    "output-management": output_management,
904 +
    "layer": layer,
905 +
    "session-lock": session_lock,
906 +
    "text-input": text_input,
907 +
    "x11": x11,
908 +
}
909 +
910 +
911 +
def main() -> None:
912 +
    """Run one selected protocol role."""
913 +
914 +
    if len(sys.argv) < 2 or sys.argv[1] not in ROLES:
915 +
        raise Failure(f"usage: clients.py {'|'.join(ROLES)} [ARGS...]")
916 +
    ROLES[sys.argv[1]](sys.argv[2:])
917 +
918 +
919 +
if __name__ == "__main__":
920 +
    try:
921 +
        main()
922 +
    except Exception as error:
923 +
        print(error, file=sys.stderr)
924 +
        raise SystemExit(1)
test/run.py added +640 -0
1 +
#!/usr/bin/env python3
2 +
3 +
"""Run swm's unit and headless integration tests."""
4 +
5 +
from __future__ import annotations
6 +
7 +
import glob
8 +
import os
9 +
import re
10 +
import shlex
11 +
import shutil
12 +
import signal
13 +
import subprocess
14 +
import sys
15 +
import time
16 +
from pathlib import Path
17 +
18 +
19 +
SCRIPT = Path(__file__).resolve()
20 +
TESTDIR = SCRIPT.parent
21 +
ROOT = TESTDIR.parent
22 +
BUILD = Path(os.environ.get("SWM_TEST_BUILD", TESTDIR / ".build")).resolve()
23 +
SOURCE = BUILD / "source"
24 +
BIN = BUILD / "bin"
25 +
PROFILES = BUILD / "profiles"
26 +
LOGS = BUILD / "logs"
27 +
CHILDREN: list[subprocess.Popen] = []
28 +
CURRENT_COMMAND: list[str] = []
29 +
CURRENT_LOG: Path | None = None
30 +
31 +
32 +
class Failure(RuntimeError):
33 +
    """Report a test failure without an internal traceback."""
34 +
35 +
36 +
def command_text(command: list[str]) -> str:
37 +
    """Format a command for diagnostics."""
38 +
39 +
    return shlex.join(map(str, command))
40 +
41 +
42 +
def run(*args: object, env: dict[str, str] | None = None) -> str:
43 +
    """Run a command and return its standard output."""
44 +
45 +
    global CURRENT_COMMAND
46 +
    command = [str(arg) for arg in args]
47 +
    CURRENT_COMMAND = command
48 +
    result = subprocess.run(command, text=True, capture_output=True, env=env)
49 +
    if result.returncode:
50 +
        output = result.stdout + result.stderr
51 +
        raise Failure(
52 +
            f"command: {command_text(command)}\nstatus: {result.returncode}\n{output}"
53 +
        )
54 +
    return result.stdout
55 +
56 +
57 +
def spawn(log: Path, *args: object, env: dict[str, str] | None = None) -> subprocess.Popen:
58 +
    """Start and track a background command."""
59 +
60 +
    global CURRENT_COMMAND, CURRENT_LOG
61 +
    command = [str(arg) for arg in args]
62 +
    CURRENT_COMMAND = command
63 +
    CURRENT_LOG = log
64 +
    log.parent.mkdir(parents=True, exist_ok=True)
65 +
    stream = log.open("w")
66 +
    process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT, env=env)
67 +
    stream.close()
68 +
    CHILDREN.append(process)
69 +
    return process
70 +
71 +
72 +
def terminate(process: subprocess.Popen) -> None:
73 +
    """Terminate one tracked process."""
74 +
75 +
    if process.poll() is None:
76 +
        process.terminate()
77 +
        try:
78 +
            process.wait(timeout=1)
79 +
        except subprocess.TimeoutExpired:
80 +
            process.kill()
81 +
            process.wait()
82 +
    if process in CHILDREN:
83 +
        CHILDREN.remove(process)
84 +
85 +
86 +
def cleanup_processes() -> None:
87 +
    """Terminate all tracked processes in reverse order."""
88 +
89 +
    for process in reversed(CHILDREN[:]):
90 +
        terminate(process)
91 +
92 +
93 +
def tail(path: Path, lines: int = 40) -> str:
94 +
    """Return the tail of a diagnostic file."""
95 +
96 +
    if not path.exists():
97 +
        return ""
98 +
    return "\n".join(path.read_text(errors="replace").splitlines()[-lines:])
99 +
100 +
101 +
def eventually(description: str, predicate, timeout: float = 2.0):
102 +
    """Wait until a predicate returns a truthy value."""
103 +
104 +
    deadline = time.monotonic() + timeout
105 +
    last: Exception | None = None
106 +
    while time.monotonic() < deadline:
107 +
        try:
108 +
            value = predicate()
109 +
            if value:
110 +
                return value
111 +
        except (OSError, ValueError, IndexError) as error:
112 +
            last = error
113 +
        time.sleep(0.02)
114 +
    detail = f": {last}" if last else ""
115 +
    raise Failure(f"timeout waiting for {description}{detail}")
116 +
117 +
118 +
def test(name: str, body) -> None:
119 +
    """Run and report one named test."""
120 +
121 +
    global CURRENT_COMMAND, CURRENT_LOG
122 +
    CURRENT_COMMAND = []
123 +
    CURRENT_LOG = None
124 +
    progress = os.environ.get("SWM_TEST_PROGRESS")
125 +
    print(f"{name} ... ", end="", flush=True)
126 +
    if progress:
127 +
        with open(progress, "a") as stream:
128 +
            stream.write(f"{name} ... ")
129 +
    try:
130 +
        body()
131 +
    except Exception:
132 +
        print("FAIL", flush=True)
133 +
        if progress:
134 +
            with open(progress, "a") as stream:
135 +
                stream.write("FAIL\n")
136 +
        raise
137 +
    print("ok", flush=True)
138 +
    if progress:
139 +
        with open(progress, "a") as stream:
140 +
            stream.write("ok\n")
141 +
142 +
143 +
def generate_python_protocols(pkgconfig_executable: str) -> None:
144 +
    """Generate Python client protocol bindings."""
145 +
146 +
    wlproto = Path(
147 +
        run(pkgconfig_executable, "--variable=pkgdatadir", "wayland-protocols").strip()
148 +
    )
149 +
    xmls = [
150 +
        Path("/usr/share/wayland/wayland.xml"),
151 +
        wlproto / "stable/xdg-shell/xdg-shell.xml",
152 +
        wlproto / "staging/ext-workspace/ext-workspace-v1.xml",
153 +
        wlproto / "staging/ext-session-lock/ext-session-lock-v1.xml",
154 +
        wlproto / "unstable/idle-inhibit/idle-inhibit-unstable-v1.xml",
155 +
        wlproto
156 +
        / "unstable/keyboard-shortcuts-inhibit/keyboard-shortcuts-inhibit-unstable-v1.xml",
157 +
        wlproto / "unstable/pointer-constraints/pointer-constraints-unstable-v1.xml",
158 +
        wlproto / "unstable/relative-pointer/relative-pointer-unstable-v1.xml",
159 +
        wlproto / "unstable/text-input/text-input-unstable-v3.xml",
160 +
        ROOT / "protocols/input-method-unstable-v2.xml",
161 +
        ROOT / "protocols/virtual-keyboard-unstable-v1.xml",
162 +
        ROOT / "protocols/wlr-foreign-toplevel-management-unstable-v1.xml",
163 +
        ROOT / "protocols/wlr-layer-shell-unstable-v1.xml",
164 +
        ROOT / "protocols/wlr-output-power-management-unstable-v1.xml",
165 +
        ROOT / "protocols/wlr-virtual-pointer-unstable-v1.xml",
166 +
    ]
167 +
    package = BUILD / "python" / "protocols"
168 +
    shutil.rmtree(package, ignore_errors=True)
169 +
    package.mkdir(parents=True)
170 +
    (package / "__init__.py").touch()
171 +
    run("pywayland-scanner", "-o", package, "-i", *xmls)
172 +
173 +
    # pywayland 0.4 generates a circular import for ext-workspace-v1. The
174 +
    # group events refer to objects that already exist, so they do not need an
175 +
    # interface constructor. Dropping that annotation breaks the cycle while
176 +
    # preserving the wire signature.
177 +
    workspace_group = package / "ext_workspace_v1/ext_workspace_group_handle_v1.py"
178 +
    generated = workspace_group.read_text()
179 +
    generated = generated.replace(
180 +
        "from .ext_workspace_handle_v1 import ExtWorkspaceHandleV1\n", ""
181 +
    ).replace(
182 +
        "Argument(ArgumentType.Object, interface=ExtWorkspaceHandleV1)",
183 +
        "Argument(ArgumentType.Object)",
184 +
    )
185 +
    workspace_group.write_text(generated)
186 +
187 +
188 +
def prepare_suite(unit: bool, integration: bool) -> None:
189 +
    """Prepare runtime files for the selected tests."""
190 +
191 +
    binaries = [
192 +
        BIN / name
193 +
        for name, selected in (("unit", unit), ("swm", integration))
194 +
        if selected
195 +
    ]
196 +
    missing = [binary for binary in binaries if not binary.exists()]
197 +
    if missing:
198 +
        raise Failure(f"test binary not built: {missing[0]}; run tests through make")
199 +
    for directory in (PROFILES, LOGS):
200 +
        shutil.rmtree(directory, ignore_errors=True)
201 +
        directory.mkdir(parents=True)
202 +
    if unit:
203 +
        stubs = BUILD / "stubs"
204 +
        shutil.rmtree(stubs, ignore_errors=True)
205 +
        stubs.mkdir()
206 +
        stub_commands = (
207 +
            "dbus-update-activation-environment",
208 +
            "foot",
209 +
            "waybar",
210 +
            "swaybg",
211 +
            "sh",
212 +
            "wl-paste",
213 +
        )
214 +
        for name in stub_commands:
215 +
            (stubs / name).symlink_to("/bin/true")
216 +
    if integration:
217 +
        for executable in ("pywayland-scanner", "timeout"):
218 +
            if not shutil.which(executable):
219 +
                raise Failure(f"required tool not found: {executable}")
220 +
        generate_python_protocols(os.environ.get("PKG_CONFIG", "pkg-config"))
221 +
        source_config = (SOURCE / "config.h").read_text()
222 +
        if re.search(r"#define\s+(?:MODKEY|MOD)\s+WLR_MODIFIER_ALT", source_config):
223 +
            modkey = 8
224 +
        elif re.search(r"#define\s+(?:MODKEY|MOD)\s+WLR_MODIFIER_LOGO", source_config):
225 +
            modkey = 64
226 +
        else:
227 +
            raise Failure("unsupported modifier in config.def.h")
228 +
        (BUILD / "modkey").write_text(str(modkey))
229 +
230 +
231 +
def run_unit() -> None:
232 +
    """Run every compositor unit test in an isolated process."""
233 +
234 +
    runtime = BUILD / "unit-runtime"
235 +
    shutil.rmtree(runtime, ignore_errors=True)
236 +
    runtime.mkdir()
237 +
    env = os.environ.copy()
238 +
    env["LLVM_PROFILE_FILE"] = str(PROFILES / "unit-%m-%p.profraw")
239 +
    env["SWM_TEST_STUB_PATH"] = str(BUILD / "stubs")
240 +
    env["SWM_TEST_DIR"] = str(runtime)
241 +
    for name in run(BIN / "unit", "--list", env=env).splitlines():
242 +
        test(name, lambda name=name: run(BIN / "unit", name, env=env))
243 +
    shutil.rmtree(runtime)
244 +
245 +
246 +
def current_title() -> str:
247 +
    """Return the compositor's published title."""
248 +
249 +
    path = Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-title"
250 +
    return path.read_text().rstrip("\n") if path.exists() else ""
251 +
252 +
253 +
def wait_title(title: str) -> None:
254 +
    """Wait for the compositor to publish a title."""
255 +
256 +
    eventually(f"title {title!r}", lambda: current_title() == title)
257 +
258 +
259 +
def client_command(role: str, *args: object) -> list[str]:
260 +
    """Return a Python protocol-client command."""
261 +
262 +
    return [sys.executable, str(TESTDIR / "clients.py"), role, *map(str, args)]
263 +
264 +
265 +
def client_log(name: str) -> Path:
266 +
    """Return a fixture client's log path."""
267 +
268 +
    return Path(os.environ["SWM_TEST_LOG_DIR"]) / f"{name}.log"
269 +
270 +
271 +
def start_client(name: str, color: str, fullscreen: bool = False) -> subprocess.Popen:
272 +
    """Start an XDG client and wait for its geometry report."""
273 +
274 +
    report = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.geometry"
275 +
    env = os.environ.copy()
276 +
    env["SWM_CLIENT_REPORT"] = str(report)
277 +
    args: list[object] = [color, name]
278 +
    if fullscreen:
279 +
        args.append("fullscreen")
280 +
    process = spawn(client_log(name), *client_command("xdg", *args), env=env)
281 +
    wait_title(name)
282 +
    eventually(f"report {report}", lambda: report.exists() and report.stat().st_size)
283 +
    return process
284 +
285 +
286 +
def geometry(name: str) -> list[int]:
287 +
    """Return a client's reported geometry."""
288 +
289 +
    path = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.geometry"
290 +
    return list(map(int, path.read_text().split()))
291 +
292 +
293 +
def protocol(role: str, *args: object) -> str:
294 +
    """Run one native-protocol action implemented in Python."""
295 +
296 +
    return run(*client_command(role, *args), env=os.environ.copy())
297 +
298 +
299 +
def virtual_keyboard(key: object, modifiers: int = 0) -> None:
300 +
    """Send one configured key combination."""
301 +
302 +
    configured = int((BUILD / "modkey").read_text()) | modifiers
303 +
    protocol("keyboard", key, configured)
304 +
305 +
306 +
def held_pointer_click(name: str, x: int, button: str | None = None) -> None:
307 +
    """Click while the compositor modifier is held by another client."""
308 +
309 +
    ready = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.ready"
310 +
    release = Path(os.environ["SWM_TEST_DIR"]) / f"{name}.release"
311 +
    env = os.environ.copy()
312 +
    env["SWM_KEYBOARD_READY"] = str(ready)
313 +
    env["SWM_KEYBOARD_RELEASE"] = str(release)
314 +
    configured = int((BUILD / "modkey").read_text())
315 +
    keyboard = spawn(
316 +
        client_log(name),
317 +
        *client_command("keyboard", "none", configured),
318 +
        env=env,
319 +
    )
320 +
    eventually("held keyboard readiness", lambda: ready.exists())
321 +
    args: list[object] = [x]
322 +
    if button:
323 +
        args.append(button)
324 +
    protocol("pointer", *args)
325 +
    release.write_text("release\n")
326 +
    eventually("held keyboard exit", lambda: keyboard.poll() is not None)
327 +
    if keyboard in CHILDREN:
328 +
        CHILDREN.remove(keyboard)
329 +
330 +
331 +
def integration_session() -> None:
332 +
    """Exercise compositor behavior in a one-output session."""
333 +
334 +
    one = start_client("one", "ff5588dd")
335 +
    two = start_client("two", "ff55aa88")
336 +
337 +
    def publication() -> None:
338 +
        windows = (Path(os.environ["XDG_RUNTIME_DIR"]) / "swm-windows").read_text()
339 +
        if not re.search(r"^\d+,\d+ \d+x\d+ one$", windows, re.MULTILINE):
340 +
            raise Failure("missing published rectangle for one")
341 +
        if not re.search(r"^\d+,\d+ \d+x\d+ two$", windows, re.MULTILINE):
342 +
            raise Failure("missing published rectangle for two")
343 +
        before = current_title()
344 +
        virtual_keyboard(36)
345 +
        wait_title("two" if before == "one" else "one")
346 +
347 +
    test("window publication and focus", publication)
348 +
349 +
    def layouts() -> None:
350 +
        virtual_keyboard(33)
351 +
        eventually("max-stack geometry", lambda: geometry("one")[:2] == geometry("two")[:2])
352 +
        virtual_keyboard(33)
353 +
        focused = current_title()
354 +
        virtual_keyboard(33, 1)
355 +
        eventually("fullscreen enable", lambda: geometry(focused)[2] == 1)
356 +
        virtual_keyboard(33, 1)
357 +
        eventually("fullscreen disable", lambda: geometry(focused)[2] == 0)
358 +
359 +
    test("layouts and fullscreen", layouts)
360 +
361 +
    def workspaces() -> None:
362 +
        current = current_title()
363 +
        virtual_keyboard(20)
364 +
        virtual_keyboard(20)
365 +
        virtual_keyboard(3, 1)
366 +
        eventually("focus after moving client", lambda: current_title() not in {"", current})
367 +
        virtual_keyboard(3)
368 +
        wait_title(current)
369 +
        virtual_keyboard(2, 1)
370 +
        wait_title("")
371 +
        virtual_keyboard(2)
372 +
        wait_title(current)
373 +
        virtual_keyboard(103)
374 +
        wait_title("")
375 +
        virtual_keyboard(108)
376 +
        wait_title(current)
377 +
378 +
    test("workspaces", workspaces)
379 +
380 +
    def layout_configuration() -> None:
381 +
        before_one = geometry("one")
382 +
        before_two = geometry("two")
383 +
        virtual_keyboard(36, 1)
384 +
        eventually(
385 +
            "client swap",
386 +
            lambda: geometry("one")[0] == before_two[0] and geometry("two")[0] == before_one[0],
387 +
        )
388 +
        for key in (28, 50, 19, 57):
389 +
            virtual_keyboard(key)
390 +
        eventually("master-top layout", lambda: geometry("one")[0] == geometry("two")[0])
391 +
        before = geometry("one")
392 +
        virtual_keyboard(35)
393 +
        eventually("master size change", lambda: geometry("one") != before)
394 +
395 +
    test("layout configuration", layout_configuration)
396 +
397 +
    def protocols() -> None:
398 +
        target = current_title()
399 +
        protocol("foreign", target, "fullscreen")
400 +
        eventually("foreign fullscreen", lambda: geometry(target)[2] == 1)
401 +
        protocol("foreign", target, "unfullscreen")
402 +
        eventually("foreign unfullscreen", lambda: geometry(target)[2] == 0)
403 +
        protocol("pointer", 100)
404 +
        protocol("pointer", 900)
405 +
        held_pointer_click("held-left", 100)
406 +
        held_pointer_click("held-right", 100, "right")
407 +
        for key, modifiers in ((51, 0), (52, 0), (51, 1), (52, 1), (57, 1), (43, 1)):
408 +
            virtual_keyboard(key, modifiers)
409 +
        protocol("output-power")
410 +
        protocol("output-management")
411 +
412 +
    test("control protocols", protocols)
413 +
414 +
    def isolation() -> None:
415 +
        protocol("workspace", 3)
416 +
        wait_title("")
417 +
        three = start_client("three", "ff8855aa")
418 +
        full = start_client("full", "ffaa5588", True)
419 +
        eventually("fullscreen state", lambda: geometry("full")[2] == 1)
420 +
        protocol("foreign", "full", "close")
421 +
        wait_title("three")
422 +
        protocol("workspace", 1)
423 +
        eventually("workspace protocol activation", lambda: current_title() in {"one", "two"})
424 +
        terminate(three)
425 +
        terminate(full)
426 +
427 +
    test("fullscreen isolation", isolation)
428 +
429 +
    def lifecycle() -> None:
430 +
        terminate(one)
431 +
        terminate(two)
432 +
        protocol("xdg-lifecycle", 2)
433 +
        protocol("transient", 2)
434 +
        protocol("layer", 2)
435 +
        protocol("session-lock", 2)
436 +
        protocol("text-input")
437 +
        protocol("x11")
438 +
439 +
    test("protocol lifecycle", lifecycle)
440 +
    terminate(one)
441 +
    terminate(two)
442 +
443 +
444 +
def integration_multioutput() -> None:
445 +
    """Exercise workspace movement and output removal."""
446 +
447 +
    client = start_client("multi", "ff778899")
448 +
449 +
    def multioutput() -> None:
450 +
        virtual_keyboard(105, 1)
451 +
        wait_title("")
452 +
        virtual_keyboard(106, 1)
453 +
        wait_title("multi")
454 +
        virtual_keyboard(105, 4)
455 +
        wait_title("")
456 +
        virtual_keyboard(105, 1)
457 +
        wait_title("multi")
458 +
        protocol("output-management", "disable-second")
459 +
        wait_title("multi")
460 +
461 +
    test("multiple outputs", multioutput)
462 +
    terminate(client)
463 +
464 +
465 +
def fixture_main(kind: str) -> None:
466 +
    """Run one fixture inside swm's process tree."""
467 +
468 +
    result = Path(os.environ["SWM_TEST_RESULT"])
469 +
    status = "ok"
470 +
    try:
471 +
        if kind == "session":
472 +
            integration_session()
473 +
        elif kind == "multioutput":
474 +
            integration_multioutput()
475 +
        else:
476 +
            raise Failure(f"unknown fixture: {kind}")
477 +
    except Exception as error:
478 +
        status = str(error)
479 +
    finally:
480 +
        cleanup_processes()
481 +
        result.write_text(status + "\n")
482 +
        os.kill(os.getppid(), signal.SIGTERM)
483 +
    if status != "ok":
484 +
        print(status, file=sys.stderr)
485 +
        raise SystemExit(1)
486 +
487 +
488 +
def run_fixture(kind: str, outputs: int) -> None:
489 +
    """Run one isolated headless compositor fixture."""
490 +
491 +
    runtime = BUILD / f"runtime-{kind}"
492 +
    state = BUILD / f"state-{kind}"
493 +
    fixture = BUILD / f"fixture-{kind}"
494 +
    logs = LOGS / kind
495 +
    for path in (runtime, state, fixture, logs):
496 +
        shutil.rmtree(path, ignore_errors=True)
497 +
        path.mkdir(parents=True, exist_ok=True)
498 +
    runtime.chmod(0o700)
499 +
    result = BUILD / f"{kind}.result"
500 +
    progress = BUILD / f"{kind}.progress"
501 +
    result.write_text("")
502 +
    progress.write_text("")
503 +
    env = os.environ.copy()
504 +
    env.update(
505 +
        {
506 +
            "XDG_RUNTIME_DIR": str(runtime),
507 +
            "XDG_STATE_HOME": str(state),
508 +
            "SWM_NO_AUTOSTART": "1",
509 +
            "WLR_BACKENDS": "headless",
510 +
            "WLR_RENDERER": "pixman",
511 +
            "WLR_LIBINPUT_NO_DEVICES": "1",
512 +
            "WLR_HEADLESS_OUTPUTS": str(outputs),
513 +
            "SWM_TEST_RESULT": str(result),
514 +
            "SWM_TEST_PROGRESS": str(progress),
515 +
            "SWM_TEST_DIR": str(fixture),
516 +
            "SWM_TEST_LOG_DIR": str(logs),
517 +
            "PYTHONPATH": str(BUILD / "python"),
518 +
            "LLVM_PROFILE_FILE": str(PROFILES / "integration-%m-%p.profraw"),
519 +
        }
520 +
    )
521 +
    log = LOGS / f"swm-{kind}.log"
522 +
    startup = f"{shlex.quote(sys.executable)} {shlex.quote(str(SCRIPT))} --fixture {kind}"
523 +
    command = ["timeout", "--foreground", "30", str(BIN / "swm"), "-s", startup]
524 +
    compositor = spawn(log, *command, env=env)
525 +
    progress_offset = 0
526 +
    while compositor.poll() is None:
527 +
        with progress.open() as stream:
528 +
            stream.seek(progress_offset)
529 +
            update = stream.read()
530 +
            progress_offset = stream.tell()
531 +
        if update:
532 +
            print(update, end="", flush=True)
533 +
        time.sleep(0.02)
534 +
    status = compositor.wait()
535 +
    CHILDREN.remove(compositor)
536 +
    with progress.open() as stream:
537 +
        stream.seek(progress_offset)
538 +
        update = stream.read()
539 +
    if update:
540 +
        print(update, end="", flush=True)
541 +
    outcome = result.read_text().strip()
542 +
    if status or outcome != "ok":
543 +
        raise Failure(
544 +
            f"fixture {kind} failed\nresult: {outcome!r}\nstatus: {status}\n"
545 +
            f"compositor log tail ({log}):\n{tail(log, 60)}"
546 +
        )
547 +
    contents = log.read_text(errors="replace")
548 +
    if re.search(r"pool exhausted|AddressSanitizer|runtime error:|protocol error", contents):
549 +
        raise Failure(f"fixture {kind} reported a runtime failure\n{tail(log, 60)}")
550 +
    for path in (runtime, state, fixture):
551 +
        shutil.rmtree(path)
552 +
553 +
554 +
def run_integration() -> None:
555 +
    """Run every integration fixture."""
556 +
557 +
    run_fixture("session", 1)
558 +
    run_fixture("multioutput", 2)
559 +
560 +
561 +
def coverage_report() -> None:
562 +
    """Merge profiles and report production coverage."""
563 +
564 +
    for executable in ("llvm-profdata", "llvm-cov"):
565 +
        if not shutil.which(executable):
566 +
            raise Failure(f"required coverage tool not found: {executable}")
567 +
    raw = glob.glob(str(PROFILES / "*.profraw"))
568 +
    if not raw:
569 +
        raise Failure("no LLVM coverage profiles were produced")
570 +
    profdata = BUILD / "coverage.profdata"
571 +
    run("llvm-profdata", "merge", "-sparse", *raw, "-o", profdata)
572 +
    report = run(
573 +
        "llvm-cov",
574 +
        "report",
575 +
        BIN / "swm",
576 +
        "-instr-profile",
577 +
        profdata,
578 +
        "-object",
579 +
        BIN / "unit",
580 +
        SOURCE / "swm.c",
581 +
        ROOT / "util.c",
582 +
    )
583 +
    files: list[str] = []
584 +
    total: list[str] | None = None
585 +
    for line in report.splitlines():
586 +
        fields = line.split()
587 +
        if len(fields) < 10:
588 +
            continue
589 +
        if fields[0] == "TOTAL":
590 +
            total = fields
591 +
        elif fields[0].endswith(".c"):
592 +
            files.append(f"{Path(fields[0]).name} {fields[9]}")
593 +
    if total is None:
594 +
        raise Failure("could not parse llvm-cov TOTAL line")
595 +
    percent = float(total[9].rstrip("%"))
596 +
    print(f"coverage: {', '.join(files)}, aggregate {percent:.2f}%")
597 +
598 +
599 +
def clean() -> None:
600 +
    """Remove all generated test artifacts."""
601 +
602 +
    shutil.rmtree(BUILD, ignore_errors=True)
603 +
    print(f"clean: removed {BUILD}")
604 +
605 +
606 +
def main() -> None:
607 +
    """Dispatch public runner modes and private fixture modes."""
608 +
609 +
    if len(sys.argv) == 3 and sys.argv[1] == "--fixture":
610 +
        fixture_main(sys.argv[2])
611 +
        return
612 +
    modes = {"unit", "integration", "coverage", "clean"}
613 +
    if len(sys.argv) > 2 or (len(sys.argv) == 2 and sys.argv[1] not in modes):
614 +
        raise Failure("usage: ./test/run.py [unit|integration|coverage|clean]")
615 +
    mode = sys.argv[1] if len(sys.argv) == 2 else "all"
616 +
    if mode == "clean":
617 +
        clean()
618 +
        return
619 +
    run_units = mode in {"all", "unit", "coverage"}
620 +
    run_integrations = mode in {"all", "integration", "coverage"}
621 +
    prepare_suite(run_units, run_integrations)
622 +
    if run_units:
623 +
        run_unit()
624 +
    if run_integrations:
625 +
        run_integration()
626 +
    if mode == "coverage":
627 +
        coverage_report()
628 +
629 +
630 +
if __name__ == "__main__":
631 +
    try:
632 +
        main()
633 +
    except Exception as error:
634 +
        print(error, file=sys.stderr)
635 +
        if CURRENT_COMMAND:
636 +
            print(f"last command: {command_text(CURRENT_COMMAND)}", file=sys.stderr)
637 +
        if CURRENT_LOG:
638 +
            print(f"log tail ({CURRENT_LOG}):\n{tail(CURRENT_LOG)}", file=sys.stderr)
639 +
        cleanup_processes()
640 +
        raise SystemExit(1)
test/unit.c added +884 -0
1 +
#include <assert.h>
2 +
#include <fcntl.h>
3 +
#include <limits.h>
4 +
#include <stdlib.h>
5 +
#include <string.h>
6 +
#include <sys/wait.h>
7 +
8 +
#define main swm_program_main
9 +
#include "swm.c"
10 +
#undef main
11 +
12 +
/* Verify that releasing a pool item clears its storage. */
13 +
static void test_pool(pool_t *pool) {
14 +
    unsigned char *item = pool_take(pool);
15 +
    size_t         i;
16 +
17 +
    assert(item);
18 +
    memset(item, 0xa5, pool->item_size);
19 +
    pool_release(pool, item);
20 +
21 +
    for (i = 0; i < pool->item_size; i++)
22 +
        assert(item[i] == 0);
23 +
}
24 +
25 +
/* Verify pool allocation limits and clearing for every compositor object type. */
26 +
static void test_pools(void) {
27 +
    client_t *allocated[MAX_CLIENTS];
28 +
    int       saved, nullfd;
29 +
    size_t    i;
30 +
31 +
    for (i = 0; i < MAX_CLIENTS; i++) {
32 +
        allocated[i] = pool_take(&client_pool);
33 +
        assert(allocated[i] == &client_items[i]);
34 +
    }
35 +
    saved  = dup(STDERR_FILENO);
36 +
    nullfd = open("/dev/null", O_WRONLY);
37 +
    assert(saved >= 0 && nullfd >= 0);
38 +
    assert(dup2(nullfd, STDERR_FILENO) >= 0);
39 +
    assert(pool_take(&client_pool) == nullptr);
40 +
    assert(dup2(saved, STDERR_FILENO) >= 0);
41 +
    close(nullfd);
42 +
    close(saved);
43 +
44 +
    for (i = 0; i < MAX_CLIENTS; i++)
45 +
        pool_release(&client_pool, allocated[i]);
46 +
47 +
    test_pool(&monitor_pool);
48 +
    test_pool(&layer_surface_pool);
49 +
    test_pool(&keyboard_group_pool);
50 +
    test_pool(&pointer_constraint_pool);
51 +
    test_pool(&text_input_pool);
52 +
    test_pool(&input_popup_pool);
53 +
    test_pool(&session_lock_pool);
54 +
    test_pool(&pending_spawn_pool);
55 +
    test_pool(&window_state_pool);
56 +
    test_pool(&static_listener_pool);
57 +
    test_pool(&workspace_manager_pool);
58 +
    test_pool(&workspace_handle_pool);
59 +
}
60 +
61 +
/* Require a geometry helper to return the expected rectangle. */
62 +
static void assert_box(struct swm_box box, int x, int y, int width, int height) {
63 +
    assert(box.x == x);
64 +
    assert(box.y == y);
65 +
    assert(box.width == width);
66 +
    assert(box.height == height);
67 +
}
68 +
69 +
/* Verify pure geometry helpers. */
70 +
static void test_geometry(void) {
71 +
    struct swm_box bounds = { 10, 20, 100, 80 };
72 +
    struct swm_box box    = { 200, 200, 0, -2 };
73 +
    struct swm_box client = { 100, 100, 300, 200 };
74 +
    struct swm_box cursor = { 20, 30, 4, 10 };
75 +
    struct swm_box output = { 0, 0, 500, 400 };
76 +
77 +
    swm_box_apply_bounds(&box, &bounds, 2);
78 +
    assert_box(box, 105, 95, 5, 5);
79 +
    box = (struct swm_box){ 10, 20, 100, 80 };
80 +
    assert_box(swm_box_resize(&box, 20, 10, 10, 1), 10, 20, 120, 90);
81 +
    assert_box(swm_box_resize(&box, 500, 500, 5, 2), 105, 95, 5, 5);
82 +
    swm_box_reconcile_commit(&box, 90, 70, 2, 5);
83 +
    assert_box(box, 16, 26, 94, 74);
84 +
    assert_box(swm_popup_position(&client, 2, &cursor, 400, 300, &output), 100, 0, 400, 300);
85 +
}
86 +
87 +
/* Verify pure workspace and stack-state helpers. */
88 +
static void test_state(void) {
89 +
    bool                   visible[]  = { false, false, false, false, false };
90 +
    bool                   occupied[] = { false, false, true, false, true };
91 +
    struct swm_stack_state state      = { 16, 1, 1 };
92 +
    int                    side;
93 +
94 +
    assert(swm_workspace_state(false, false, false) == SWM_WORKSPACE_HIDDEN);
95 +
    assert(swm_workspace_state(true, true, true) == (SWM_WORKSPACE_ACTIVE | SWM_WORKSPACE_URGENT));
96 +
    assert(swm_border_width(3, false, false, false, false, false) == 3);
97 +
    assert(swm_border_width(3, true, false, false, false, false) == 0);
98 +
    assert(swm_workspace_next(0, 5, 1, false, visible, occupied) == 2);
99 +
    assert(swm_workspace_next(0, 5, -1, false, visible, occupied) == 4);
100 +
    visible[2] = true;
101 +
    assert(swm_workspace_next(0, 5, 1, false, visible, occupied) == 4);
102 +
    assert(swm_workspace_next(0, 5, 1, true, visible, occupied) == 1);
103 +
    assert(swm_stack_configure(&state, SWM_MASTER_SHRINK, SWM_MASTER_LEFT, &side));
104 +
    assert(state.msize == 15 && side == SWM_MASTER_LEFT);
105 +
106 +
    for (int command = SWM_MASTER_GROW; command <= SWM_FLIP_LAYOUT; command++)
107 +
        assert(swm_stack_configure(&state, command, SWM_MASTER_LEFT, &side));
108 +
    assert(!swm_stack_configure(&state, 99, SWM_MASTER_LEFT, &side));
109 +
}
110 +
111 +
/* Verify pure rule and persistent-state parsing helpers. */
112 +
static void test_parsing(void) {
113 +
    char           small[5], appid[MAX_WINDOW_STATE_FIELD], title[MAX_WINDOW_STATE_FIELD];
114 +
    struct swm_box geometry;
115 +
116 +
    assert(swm_rule_matches("*", nullptr, "anything", "title"));
117 +
    assert(swm_rule_matches("term", "shell", "my-terminal", "a shell"));
118 +
    assert(!swm_rule_matches("browser", nullptr, "terminal", "browser"));
119 +
    swm_sanitize_field(small, sizeof(small), "a\tb\nc", nullptr);
120 +
    assert(!strcmp(small, "a b "));
121 +
    assert(swm_parse_window_state(
122 +
        "org.app\tWindow\t1\t-2\t300\t200\n", appid, sizeof(appid), title, sizeof(title), &geometry
123 +
    ));
124 +
    assert(!strcmp(appid, "org.app"));
125 +
    assert(!strcmp(title, "Window"));
126 +
    assert_box(geometry, 1, -2, 300, 200);
127 +
    assert(
128 +
        !swm_parse_window_state("broken", appid, sizeof(appid), title, sizeof(title), &geometry)
129 +
    );
130 +
}
131 +
132 +
/* Verify layout cycling and complete output-area partitioning. */
133 +
static void test_layouts(void) {
134 +
    bool                   cycle[] = { true, true, false, true };
135 +
    struct swm_box         area    = { -3, 7, 64, 61 };
136 +
    struct swm_box         boxes[64];
137 +
    struct swm_stack_state state;
138 +
    size_t                 count, produced;
139 +
    int                    rotate, flip, masters, stacks;
140 +
141 +
    assert(swm_next_layout(0, 4, cycle) == 1);
142 +
    assert(swm_next_layout(1, 4, cycle) == 3);
143 +
    assert(swm_next_layout(3, 4, cycle) == 0);
144 +
145 +
    for (rotate = 0; rotate <= 1; rotate++)
146 +
        for (flip = 0; flip <= 1; flip++)
147 +
            for (masters = 0; masters < 5; masters++)
148 +
                for (stacks = 1; stacks < 5; stacks++)
149 +
                    for (count = 1; count < 13; count++) {
150 +
                        int covered = 0;
151 +
152 +
                        state    = (struct swm_stack_state){ 13, masters, stacks };
153 +
                        produced = swm_stack_layout(&area, &state, rotate, flip, count, boxes);
154 +
                        assert(produced == count);
155 +
156 +
                        for (size_t i = 0; i < count; i++)
157 +
                            covered += boxes[i].width * boxes[i].height;
158 +
                        assert(covered == area.width * area.height);
159 +
                    }
160 +
}
161 +
162 +
/* Verify environment expansion and output bounds. */
163 +
static void test_utilities(void) {
164 +
    char output[64];
165 +
166 +
    assert(setenv("SWM_TEST_ENV", "hello world", 1) == 0);
167 +
    assert(env_expand(output, sizeof(output), "$SWM_TEST_ENV/tail") == 17);
168 +
    assert(!strcmp(output, "hello world/tail"));
169 +
    assert(env_expand(output, 4, "$SWM_TEST_ENV") == 0);
170 +
}
171 +
172 +
/* Verify free-workspace selection and published workspace states. */
173 +
static void test_workspace_queries(void) {
174 +
    client_t    *client;
175 +
    workspace_t *workspace;
176 +
    int          i;
177 +
178 +
    for (i = 0; i < WSCOUNT; i++) {
179 +
        workspaces[i].mon = (monitor_t *)1;
180 +
        wl_list_init(&workspaces[i].handles);
181 +
    }
182 +
    workspaces[4].mon = nullptr;
183 +
    assert(free_workspace() == &workspaces[4]);
184 +
    workspaces[4].mon = (monitor_t *)1;
185 +
    assert(free_workspace() == nullptr);
186 +
187 +
    wl_list_init(&clients);
188 +
    workspace      = &workspaces[2];
189 +
    workspace->mon = nullptr;
190 +
    assert(workspace_state(workspace) == EXT_WORKSPACE_HANDLE_V1_STATE_HIDDEN);
191 +
    client = pool_take(&client_pool);
192 +
    assert(client);
193 +
    client->ws        = workspace;
194 +
    client->is_urgent = 1;
195 +
    wl_list_insert(&clients, &client->link);
196 +
    assert(workspace_state(workspace) == EXT_WORKSPACE_HANDLE_V1_STATE_URGENT);
197 +
    workspace->mon = (monitor_t *)1;
198 +
    assert(
199 +
        workspace_state(workspace) ==
200 +
        (EXT_WORKSPACE_HANDLE_V1_STATE_ACTIVE | EXT_WORKSPACE_HANDLE_V1_STATE_URGENT)
201 +
    );
202 +
    wl_list_remove(&client->link);
203 +
    pool_release(&client_pool, client);
204 +
}
205 +
206 +
/* Verify state-file paths and client border-width policy. */
207 +
static void test_paths_and_border_policy(void) {
208 +
    const char *directory = getenv("SWM_TEST_DIR");
209 +
    client_t    client    = {};
210 +
    char        path[PATH_MAX], expected[PATH_MAX];
211 +
212 +
    assert(directory);
213 +
    assert(snprintf(path, sizeof(path), "%s/state", directory) < (int)sizeof(path));
214 +
    assert(setenv("XDG_STATE_HOME", path, 1) == 0);
215 +
    assert(snprintf(expected, sizeof(expected), "%s/swm/windows", path) < (int)sizeof(expected));
216 +
    assert(!strcmp(window_state_path(), expected));
217 +
    unsetenv("XDG_STATE_HOME");
218 +
    assert(snprintf(path, sizeof(path), "%s/home", directory) < (int)sizeof(path));
219 +
    assert(setenv("HOME", path, 1) == 0);
220 +
    assert(
221 +
        snprintf(expected, sizeof(expected), "%s/.local/state/swm/windows", path) <
222 +
        (int)sizeof(expected)
223 +
    );
224 +
    assert(!strcmp(window_state_path(), expected));
225 +
    client.bw = 9;
226 +
    assert(client_border_width(&client) == borderwidth);
227 +
    client.is_fullscreen = 1;
228 +
    assert(client_border_width(&client) == 0);
229 +
}
230 +
231 +
/* Initialize a minimal XDG client object for internal tests. */
232 +
static void init_xdg_client(
233 +
    client_t                *client,
234 +
    struct wlr_xdg_surface  *surface,
235 +
    struct wlr_xdg_toplevel *toplevel,
236 +
    const char              *appid,
237 +
    const char              *title
238 +
) {
239 +
    memset(client, 0, sizeof(*client));
240 +
    memset(surface, 0, sizeof(*surface));
241 +
    memset(toplevel, 0, sizeof(*toplevel));
242 +
    client->type        = XDG_SHELL;
243 +
    client->surface.xdg = surface;
244 +
    surface->toplevel   = toplevel;
245 +
    toplevel->base      = surface;
246 +
    toplevel->app_id    = (char *)appid;
247 +
    toplevel->title     = (char *)title;
248 +
}
249 +
250 +
/* Verify matching rules update client state and workspace assignment. */
251 +
static void test_rule_application(void) {
252 +
    client_t                client;
253 +
    struct wlr_xdg_surface  surface;
254 +
    struct wlr_xdg_toplevel toplevel;
255 +
256 +
    init_xdg_client(&client, &surface, &toplevel, "org.telegram.desktop", "Telegram");
257 +
    wl_list_init(&mons);
258 +
    selmon = nullptr;
259 +
    apply_rules(&client, nullptr);
260 +
    assert(client.is_floating == 1);
261 +
    assert(client.is_borderless == 1);
262 +
    assert(client.mon == nullptr);
263 +
}
264 +
265 +
/* Verify focus queries across mapped, hidden, and unmanaged clients. */
266 +
static void test_focus_queries(void) {
267 +
    client_t                first, second, hidden;
268 +
    struct wlr_xdg_surface  surfaces[3];
269 +
    struct wlr_xdg_toplevel toplevels[3];
270 +
    monitor_t               monitor = {};
271 +
    workspace_t             shown = {}, other = {};
272 +
273 +
    shown.mon  = &monitor;
274 +
    monitor.ws = &shown;
275 +
    other.mon  = nullptr;
276 +
    init_xdg_client(&first, &surfaces[0], &toplevels[0], "first", "First");
277 +
    init_xdg_client(&second, &surfaces[1], &toplevels[1], "second", "Second");
278 +
    init_xdg_client(&hidden, &surfaces[2], &toplevels[2], "hidden", "Hidden");
279 +
    first.ws = second.ws = &shown;
280 +
    hidden.ws            = &other;
281 +
    first.mon = second.mon = &monitor;
282 +
283 +
    wl_list_init(&clients);
284 +
    wl_list_init(&fstack);
285 +
    wl_list_insert(clients.prev, &first.link);
286 +
    wl_list_insert(clients.prev, &second.link);
287 +
    wl_list_insert(clients.prev, &hidden.link);
288 +
    wl_list_insert(&fstack, &hidden.flink);
289 +
    wl_list_insert(&fstack, &first.flink);
290 +
    wl_list_insert(&fstack, &second.flink);
291 +
    assert(focus_top(&monitor) == &second);
292 +
    assert(focus_top(nullptr) == nullptr);
293 +
    assert(focus_close(&second) == &first);
294 +
    assert(focus_close(nullptr) == nullptr);
295 +
    assert(focus_close(&hidden) == nullptr);
296 +
    assert(client_main(nullptr) == nullptr);
297 +
    assert(!clients_related(nullptr, &first));
298 +
    assert(clients_related(&first, &first));
299 +
}
300 +
301 +
/* Release every saved window-state entry. */
302 +
static void clear_window_states(void) {
303 +
    window_state_t *state, *tmp;
304 +
305 +
    wl_list_for_each_safe(state, tmp, &window_states, link) {
306 +
        wl_list_remove(&state->link);
307 +
        pool_release(&window_state_pool, state);
308 +
    }
309 +
}
310 +
311 +
/* Verify saving, loading, finding, and forgetting persistent window state. */
312 +
static void test_window_state_storage(void) {
313 +
    char                    statehome[PATH_MAX];
314 +
    char                    filepath[MAX_STATE_PATH];
315 +
    client_t                client;
316 +
    struct wlr_xdg_surface  surface;
317 +
    struct wlr_xdg_toplevel toplevel;
318 +
    window_state_t         *state;
319 +
    FILE                   *file;
320 +
321 +
    assert(
322 +
        snprintf(statehome, sizeof(statehome), "%s/window-state-XXXXXX", getenv("SWM_TEST_DIR")) <
323 +
        (int)sizeof(statehome)
324 +
    );
325 +
    assert(mkdtemp(statehome));
326 +
    setenv("XDG_STATE_HOME", statehome, 1);
327 +
    wl_list_init(&window_states);
328 +
    init_xdg_client(&client, &surface, &toplevel, "org.test\tapp", "Window\nTitle");
329 +
    client.persist_float = client.is_floating = 1;
330 +
    client.geom                               = (struct wlr_box){ 11, -12, 640, 480 };
331 +
    remember_client(&client);
332 +
    state = find_window_state(&client);
333 +
    assert(state);
334 +
    assert(!strcmp(state->appid, "org.test app"));
335 +
    assert(!strcmp(state->title, "Window Title"));
336 +
    assert(state->geom.x == 11 && state->geom.height == 480);
337 +
338 +
    snprintf(filepath, sizeof(filepath), "%s/swm/windows", statehome);
339 +
    file = fopen(filepath, "r");
340 +
    assert(file);
341 +
    assert(fclose(file) == 0);
342 +
    clear_window_states();
343 +
    load_window_states();
344 +
    assert(find_window_state(&client));
345 +
    forget_client(&client);
346 +
    assert(find_window_state(&client) == nullptr);
347 +
348 +
    file = fopen(filepath, "w");
349 +
    assert(file);
350 +
    assert(fputs("bad line\nlegacy\t1\t2\t3\t4\n", file) >= 0);
351 +
    assert(fclose(file) == 0);
352 +
    load_window_states();
353 +
    assert(!wl_list_empty(&window_states));
354 +
    clear_window_states();
355 +
    unlink(filepath);
356 +
    snprintf(filepath, sizeof(filepath), "%s/swm", statehome);
357 +
    rmdir(filepath);
358 +
    rmdir(statehome);
359 +
}
360 +
361 +
/* Verify SIGCHLD handling releases tracked child processes. */
362 +
static void test_child_reaping(void) {
363 +
    pending_spawn_t *spawned;
364 +
    struct timespec  delay = { 0, 1000000 };
365 +
    pid_t            pid;
366 +
    int              i;
367 +
368 +
    wl_list_init(&pending_spawns);
369 +
    pid = fork();
370 +
    assert(pid >= 0);
371 +
372 +
    if (pid == 0)
373 +
        _exit(0);
374 +
    spawned = pool_take(&pending_spawn_pool);
375 +
    assert(spawned);
376 +
    spawned->pid = pid;
377 +
    wl_list_insert(&pending_spawns, &spawned->link);
378 +
    child_pid = pid;
379 +
380 +
    for (i = 0; i < 100 && !wl_list_empty(&pending_spawns); i++) {
381 +
        handle_signal(SIGCHLD, nullptr);
382 +
        nanosleep(&delay, nullptr);
383 +
    }
384 +
    assert(wl_list_empty(&pending_spawns));
385 +
    assert(child_pid == -1);
386 +
    assert(handle_signal(0, nullptr) == 0);
387 +
}
388 +
389 +
/* Verify spawned commands are tracked and reaped. */
390 +
static void test_spawn_tracking(void) {
391 +
    const char     *command[] = { "/bin/true", nullptr };
392 +
    monitor_t       monitor   = {};
393 +
    workspace_t     workspace = {};
394 +
    struct timespec delay     = { 0, 1000000 };
395 +
    int             i;
396 +
397 +
    assert(sigprocmask(SIG_BLOCK, nullptr, &original_signal_mask) == 0);
398 +
    wl_list_init(&pending_spawns);
399 +
    monitor.ws = &workspace;
400 +
    selmon     = &monitor;
401 +
    spawn(&(arg_t){ .v = command });
402 +
    assert(!wl_list_empty(&pending_spawns));
403 +
404 +
    for (i = 0; i < 100 && !wl_list_empty(&pending_spawns); i++) {
405 +
        handle_signal(SIGCHLD, nullptr);
406 +
        nanosleep(&delay, nullptr);
407 +
    }
408 +
    assert(wl_list_empty(&pending_spawns));
409 +
    selmon = nullptr;
410 +
}
411 +
412 +
/* Verify client commands remain safe when no client can receive them. */
413 +
static void test_commands_without_clients(void) {
414 +
    struct wlr_output output  = {};
415 +
    monitor_t         monitor = {};
416 +
    arg_t             argument;
417 +
    int               i;
418 +
419 +
    monitor.wlr_output   = &output;
420 +
    monitor.ws           = &workspaces[0];
421 +
    workspaces[0].mon    = &monitor;
422 +
    workspaces[0].lt     = &layouts[0];
423 +
    workspaces[0].prevlt = nullptr;
424 +
    workspaces[0].v = workspaces[0].h = (stack_state_t){ 16, 1, 1 };
425 +
426 +
    for (i = 1; i < WSCOUNT; i++)
427 +
        workspaces[i].mon = (monitor_t *)1;
428 +
    wl_list_init(&clients);
429 +
    wl_list_init(&fstack);
430 +
    wl_list_init(&mons);
431 +
    wl_list_init(&ws_managers);
432 +
    selmon = &monitor;
433 +
    unsetenv("XDG_RUNTIME_DIR");
434 +
435 +
    cycle_layout(nullptr);
436 +
    assert(workspaces[0].lt == &layouts[1]);
437 +
    assert(workspaces[0].prevlt == &layouts[0]);
438 +
    toggle_max_stack(nullptr);
439 +
    assert(workspaces[0].lt == &layouts[4]);
440 +
    toggle_max_stack(nullptr);
441 +
    assert(workspaces[0].lt == &layouts[1]);
442 +
    argument.i = MASTER_GROW;
443 +
    stack_config(&argument);
444 +
    assert(workspaces[0].h.msize == 17);
445 +
    argument.i = LAYOUT_FLIP;
446 +
    stack_config(&argument);
447 +
    assert(workspaces[0].lt == &layouts[3]);
448 +
449 +
    assert(focus_top(&monitor) == nullptr);
450 +
    focus_stack(&(arg_t){ .i = 1 });
451 +
    focus_main(nullptr);
452 +
    focus_urgent(nullptr);
453 +
    tag(&(arg_t){ .i = 0 });
454 +
    tag(&(arg_t){ .i = WSCOUNT });
455 +
    tag_monitor(&(arg_t){ .i = WLR_DIRECTION_LEFT });
456 +
    toggle_floating(nullptr);
457 +
    toggle_fullscreen(nullptr);
458 +
    kill_client(nullptr);
459 +
    raise_client(nullptr);
460 +
    zoom(nullptr);
461 +
    argument.i = WORKSPACE_NEXT;
462 +
    cycle_workspace(&argument);
463 +
    assert(monitor.ws == &workspaces[0]);
464 +
    view(&(arg_t){ .i = WSCOUNT });
465 +
466 +
    selmon = nullptr;
467 +
    cycle_layout(nullptr);
468 +
    toggle_max_stack(nullptr);
469 +
    stack_config(&argument);
470 +
    cycle_workspace(&argument);
471 +
    view(&(arg_t){ .i = 0 });
472 +
}
473 +
474 +
/* Verify small geometry, focus, inhibition, and listener helpers. */
475 +
static void test_small_helpers(void) {
476 +
    client_t           client = { .geom = { -20, 100, 2, 2 }, .bw = 2 };
477 +
    struct wlr_box     bounds = { 0, 0, 80, 60 };
478 +
    static_listener_t *slot;
479 +
    struct wlr_seat    fake_seat = {};
480 +
481 +
    apply_bounds(&client, &bounds);
482 +
    assert(client.geom.x == 0 && client.geom.y == 55);
483 +
    assert(client.geom.width == 5 && client.geom.height == 5);
484 +
    seat = &fake_seat;
485 +
    assert(shortcuts_inhibited() == 0);
486 +
    assert(focus_close(nullptr) == nullptr);
487 +
    slot = pool_take(&static_listener_pool);
488 +
    assert(slot);
489 +
    listener_release(&slot->listener);
490 +
}
491 +
492 +
/* Verify XDG and XWayland client accessors without a running compositor. */
493 +
static void test_client_accessors(void) {
494 +
    client_t                    client   = {};
495 +
    struct wlr_xdg_surface      xdg      = { .geometry = { 3, 4, 50, 60 } };
496 +
    struct wlr_xdg_toplevel     toplevel = {};
497 +
    struct wlr_xwayland_surface xwayland = {
498 +
        .class             = "x11-app",
499 +
        .title             = "X11 title",
500 +
        .x                 = 7,
501 +
        .y                 = 8,
502 +
        .width             = 90,
503 +
        .height            = 100,
504 +
        .modal             = true,
505 +
        .override_redirect = true,
506 +
        .fullscreen        = true,
507 +
    };
508 +
    xcb_size_hints_t size_hints = {
509 +
        .min_width  = 20,
510 +
        .min_height = 20,
511 +
        .max_width  = 20,
512 +
        .max_height = 40,
513 +
    };
514 +
    struct wlr_box    box;
515 +
    char              storage[MAX_COMMAND_SIZE], *expanded[MAX_COMMAND_ARGS];
516 +
    const char *const command[] = { "before-$SWM_TEST_EXPAND", "$SWM_TEST_MISSING", nullptr };
517 +
518 +
    init_xdg_client(&client, &xdg, &toplevel, "xdg-app", "XDG title");
519 +
    xdg.geometry = (struct wlr_box){ 3, 4, 50, 60 };
520 +
    client.geom  = (struct wlr_box){ 1, 2, 70, 80 };
521 +
    client.bw    = 2;
522 +
    assert(!strcmp(client_get_appid(&client), "xdg-app"));
523 +
    assert(!strcmp(client_get_title(&client), "XDG title"));
524 +
    client_get_clip(&client, &box);
525 +
    assert(box.x == 3 && box.y == 4 && box.width == 68 && box.height == 78);
526 +
    client_get_geometry(&client, &box);
527 +
    assert(box.x == 3 && box.y == 4 && box.width == 50 && box.height == 60);
528 +
    assert(client_get_parent(&client) == nullptr);
529 +
    assert(!client_is_float_type(&client));
530 +
    toplevel.current = (struct wlr_xdg_toplevel_state){
531 +
        .min_width  = 10,
532 +
        .min_height = 10,
533 +
        .max_width  = 10,
534 +
        .max_height = 20,
535 +
    };
536 +
    assert(client_is_float_type(&client));
537 +
    assert(!client_is_unmanaged(&client));
538 +
    toplevel.requested.fullscreen = true;
539 +
    assert(client_wants_fullscreen(&client));
540 +
541 +
    client.type             = X11;
542 +
    client.surface.xwayland = &xwayland;
543 +
    assert(client_set_bounds(&client, 10, 20) == 0);
544 +
    assert(!strcmp(client_get_appid(&client), "x11-app"));
545 +
    assert(!strcmp(client_get_title(&client), "X11 title"));
546 +
    client_get_clip(&client, &box);
547 +
    assert(box.x == 0 && box.y == 0);
548 +
    client_get_geometry(&client, &box);
549 +
    assert(box.x == 7 && box.y == 8 && box.width == 90 && box.height == 100);
550 +
    assert(client_get_parent(&client) == nullptr);
551 +
    assert(client_is_float_type(&client));
552 +
    assert(client_is_unmanaged(&client));
553 +
    assert(client_wants_fullscreen(&client));
554 +
    xwayland.modal      = false;
555 +
    xwayland.size_hints = &size_hints;
556 +
    assert(client_is_float_type(&client));
557 +
    xwayland.class = xwayland.title = nullptr;
558 +
    assert(!strcmp(client_get_appid(&client), "broken"));
559 +
    assert(!strcmp(client_get_title(&client), "broken"));
560 +
    client_set_resizing(&client, true);
561 +
    client_set_suspended(&client, true);
562 +
563 +
    setenv("SWM_TEST_EXPAND", "expanded value", 1);
564 +
    unsetenv("SWM_TEST_MISSING");
565 +
    expand_argv(command, expanded, storage);
566 +
    assert(!strcmp(expanded[0], "before-expanded value"));
567 +
    assert(!strcmp(expanded[1], ""));
568 +
    assert(expanded[2] == nullptr);
569 +
    publish_windows(nullptr);
570 +
}
571 +
572 +
/* Verify configured autostart commands and child tracking with harmless stubs. */
573 +
static void test_autostart_execution(void) {
574 +
    const char     *stub_path = getenv("SWM_TEST_STUB_PATH");
575 +
    const char     *old_path  = getenv("PATH");
576 +
    char           *saved_path;
577 +
    struct timespec delay = { 0, 1000000 };
578 +
    size_t          i;
579 +
    int             pending;
580 +
581 +
    assert(stub_path && old_path);
582 +
    saved_path = strdup(old_path);
583 +
    assert(saved_path);
584 +
    assert(setenv("PATH", stub_path, 1) == 0);
585 +
    assert(sigprocmask(SIG_BLOCK, nullptr, &original_signal_mask) == 0);
586 +
    wl_list_init(&pending_spawns);
587 +
    autostart_len = 0;
588 +
    autostart_exec();
589 +
    assert(autostart_len > 0);
590 +
591 +
    for (i = 0; i < 100; i++) {
592 +
        pending = 0;
593 +
        handle_signal(SIGCHLD, nullptr);
594 +
595 +
        for (size_t j = 0; j < autostart_len; j++)
596 +
            pending |= autostart_pids[j] > 0;
597 +
598 +
        if (!pending)
599 +
            break;
600 +
        nanosleep(&delay, nullptr);
601 +
    }
602 +
    assert(!pending);
603 +
    assert(setenv("PATH", saved_path, 1) == 0);
604 +
    free(saved_path);
605 +
}
606 +
607 +
/* Verify forwarding of every pointer-gesture event family. */
608 +
static void test_pointer_gestures(void) {
609 +
    struct wlr_pointer_swipe_begin_event  swipe_begin  = { .time_msec = 1, .fingers = 3 };
610 +
    struct wlr_pointer_swipe_update_event swipe_update = { .time_msec = 2, .dx = 1, .dy = -1 };
611 +
    struct wlr_pointer_swipe_end_event    swipe_end    = { .time_msec = 3, .cancelled = false };
612 +
    struct wlr_pointer_pinch_begin_event  pinch_begin  = { .time_msec = 4, .fingers = 2 };
613 +
    struct wlr_pointer_pinch_update_event pinch_update = {
614 +
        .time_msec = 5,
615 +
        .dx        = 1,
616 +
        .dy        = 2,
617 +
        .scale     = 1.1,
618 +
        .rotation  = 3,
619 +
    };
620 +
    struct wlr_pointer_pinch_end_event  pinch_end  = { .time_msec = 6, .cancelled = true };
621 +
    struct wlr_pointer_hold_begin_event hold_begin = { .time_msec = 7, .fingers = 2 };
622 +
    struct wlr_pointer_hold_end_event   hold_end   = { .time_msec = 8, .cancelled = false };
623 +
624 +
    dpy = wl_display_create();
625 +
    assert(dpy);
626 +
    seat             = wlr_seat_create(dpy, "test-seat");
627 +
    idle_notifier    = wlr_idle_notifier_v1_create(dpy);
628 +
    pointer_gestures = wlr_pointer_gestures_v1_create(dpy);
629 +
    assert(seat && idle_notifier && pointer_gestures);
630 +
    gesture_swipe_begin(nullptr, &swipe_begin);
631 +
    gesture_swipe_update(nullptr, &swipe_update);
632 +
    gesture_swipe_end(nullptr, &swipe_end);
633 +
    gesture_pinch_begin(nullptr, &pinch_begin);
634 +
    gesture_pinch_update(nullptr, &pinch_update);
635 +
    gesture_pinch_end(nullptr, &pinch_end);
636 +
    gesture_hold_begin(nullptr, &hold_begin);
637 +
    gesture_hold_end(nullptr, &hold_end);
638 +
    wl_display_destroy(dpy);
639 +
    dpy = nullptr;
640 +
}
641 +
642 +
/* Verify text-input and input-method wrappers are created and released. */
643 +
static void test_input_method_lifecycle(void) {
644 +
    struct wlr_seat                  fake_seat       = {};
645 +
    struct wlr_text_input_manager_v3 fake_ti_manager = {};
646 +
    struct wlr_text_input_v3         text_input      = {};
647 +
    struct wlr_input_method_v2       method          = {};
648 +
    text_input_t                    *wrapper         = nullptr;
649 +
    size_t                           i;
650 +
651 +
    seat   = &fake_seat;
652 +
    ti_mgr = &fake_ti_manager;
653 +
    wl_list_init(&fake_ti_manager.text_inputs);
654 +
    assert(input_method_focused_text_input() == nullptr);
655 +
    input_method = nullptr;
656 +
    input_method_send_state(&text_input);
657 +
    input_method_commit_notify(nullptr, &method);
658 +
659 +
    wl_signal_init(&text_input.events.enable);
660 +
    wl_signal_init(&text_input.events.commit);
661 +
    wl_signal_init(&text_input.events.disable);
662 +
    wl_signal_init(&text_input.events.destroy);
663 +
    text_input_create_notify(nullptr, &text_input);
664 +
665 +
    for (i = 0; i < LENGTH(text_input_used); i++)
666 +
667 +
        if (text_input_used[i]) {
668 +
            wrapper = &text_input_items[i];
669 +
            break;
670 +
        }
671 +
    assert(wrapper && wrapper->ti == &text_input);
672 +
    text_input_enable_notify(&wrapper->enable, nullptr);
673 +
    text_input_commit_notify(&wrapper->commit, nullptr);
674 +
    text_input_disable_notify(&wrapper->disable, nullptr);
675 +
    text_input_destroy_notify(&wrapper->destroy, nullptr);
676 +
    assert(!text_input_used[wrapper - text_input_items]);
677 +
678 +
    wl_signal_init(&method.events.commit);
679 +
    wl_signal_init(&method.events.destroy);
680 +
    wl_signal_init(&method.events.grab_keyboard);
681 +
    wl_signal_init(&method.events.new_popup_surface);
682 +
    input_method_create_notify(nullptr, &method);
683 +
    assert(input_method == &method);
684 +
    input_method_destroy_notify(nullptr, nullptr);
685 +
    assert(input_method == nullptr);
686 +
}
687 +
688 +
/* Verify input handlers safely reject incomplete or inapplicable state. */
689 +
static void test_input_early_paths(void) {
690 +
    struct wlr_seat                                            fake_seat    = {};
691 +
    struct wlr_seat_pointer_request_set_cursor_event           cursor_event = {};
692 +
    struct wlr_cursor_shape_manager_v1_request_set_shape_event shape_event  = {};
693 +
    struct wlr_drag                                            drag         = {};
694 +
    keyboard_group_t                                           group        = {};
695 +
    monitor_t                                                  monitor      = {};
696 +
    workspace_t                                                workspace    = {};
697 +
    struct wlr_output                                          output       = {};
698 +
699 +
    seat        = &fake_seat;
700 +
    cursor_mode = CURSOR_MOVE;
701 +
    set_cursor(nullptr, &cursor_event);
702 +
    set_cursor_shape(nullptr, &shape_event);
703 +
    start_drag(nullptr, &drag);
704 +
    assert(key_repeat(&group) == 0);
705 +
    assert(key_binding(0, XKB_KEY_NoSymbol, 0) == 0);
706 +
707 +
    wl_list_init(&clients);
708 +
    wl_list_init(&fstack);
709 +
    monitor.wlr_output = &output;
710 +
    monitor.ws         = &workspace;
711 +
    workspace.mon      = &monitor;
712 +
    workspace.lt       = &layouts[0];
713 +
    workspace.v = workspace.h = (stack_state_t){ 16, 1, 1 };
714 +
    master_left(&monitor);
715 +
    master_right(&monitor);
716 +
    master_top(&monitor);
717 +
    master_bottom(&monitor);
718 +
    max_stack(&monitor);
719 +
    move_resize(&(arg_t){ .u = CURSOR_MOVE });
720 +
}
721 +
722 +
/* Assert that the program exits with the requested status. */
723 +
static void assert_program_exits(int argc, char **argv, int clear_runtime, int code) {
724 +
    int   status;
725 +
    pid_t pid = fork();
726 +
727 +
    assert(pid >= 0);
728 +
729 +
    if (pid == 0) {
730 +
        close(STDERR_FILENO);
731 +
732 +
        if (clear_runtime)
733 +
            unsetenv("XDG_RUNTIME_DIR");
734 +
        optind = 1;
735 +
        _exit(swm_program_main(argc, argv));
736 +
    }
737 +
    assert(waitpid(pid, &status, 0) == pid);
738 +
    assert(WIFEXITED(status) && WEXITSTATUS(status) == code);
739 +
}
740 +
741 +
/* Assert that the program exits with failure status. */
742 +
static void assert_program_fails(int argc, char **argv, int clear_runtime) {
743 +
    assert_program_exits(argc, argv, clear_runtime, 1);
744 +
}
745 +
746 +
/* Verify version output and invalid command-line invocations. */
747 +
static void test_command_line_errors(void) {
748 +
    char *version[]         = { "swm", "-v", nullptr };
749 +
    char *help[]            = { "swm", "-h", nullptr };
750 +
    char *extra[]           = { "swm", "extra", nullptr };
751 +
    char *missing_runtime[] = { "swm", nullptr };
752 +
753 +
    assert_program_exits(2, version, 0, 0); /* -v prints the version and succeeds. */
754 +
    assert_program_fails(2, help, 0);
755 +
    assert_program_fails(2, extra, 0);
756 +
    assert_program_fails(1, missing_runtime, 1);
757 +
}
758 +
759 +
/* Verify pointer-constraint and decoration lifecycle handlers. */
760 +
static void test_pointer_and_decoration_lifecycle(void) {
761 +
    struct wlr_pointer_constraint_v1      constraint = {};
762 +
    struct wlr_surface                    surface    = {};
763 +
    pointer_constraint_t                 *wrapper    = nullptr;
764 +
    struct wlr_xdg_surface                xdg        = {};
765 +
    struct wlr_xdg_toplevel               toplevel   = {};
766 +
    struct wlr_xdg_toplevel_decoration_v1 decoration = {};
767 +
    client_t                              client     = {};
768 +
    struct wlr_seat                       fake_seat  = {};
769 +
    size_t                                i;
770 +
771 +
    seat               = &fake_seat;
772 +
    constraint.surface = &surface;
773 +
    wl_signal_init(&constraint.events.destroy);
774 +
    create_pointer_constraint(nullptr, &constraint);
775 +
776 +
    for (i = 0; i < LENGTH(pointer_constraint_used); i++)
777 +
778 +
        if (pointer_constraint_used[i]) {
779 +
            wrapper = &pointer_constraint_items[i];
780 +
            break;
781 +
        }
782 +
    assert(wrapper && wrapper->constraint == &constraint);
783 +
    destroy_pointer_constraint(&wrapper->destroy, nullptr);
784 +
    assert(!pointer_constraint_used[wrapper - pointer_constraint_items]);
785 +
    cursor_constrain(nullptr);
786 +
    cursor_constrain(nullptr);
787 +
788 +
    client.type               = XDG_SHELL;
789 +
    client.surface.xdg        = &xdg;
790 +
    client.decoration         = &decoration;
791 +
    xdg.toplevel              = &toplevel;
792 +
    toplevel.base             = &xdg;
793 +
    decoration.toplevel       = &toplevel;
794 +
    decoration.requested_mode = WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE;
795 +
    request_decoration_mode(&client.set_decoration_mode, nullptr);
796 +
    assert(client.bw == 0);
797 +
    maximize_notify(&client.maximize, nullptr);
798 +
    create_decoration(nullptr, &decoration);
799 +
    urgent(nullptr, &(struct wlr_xdg_activation_v1_request_activate_event){});
800 +
}
801 +
802 +
/* Verify modifier handling, repeatable bindings, and key-repeat dispatch. */
803 +
static void test_keybindings_and_repeat(void) {
804 +
    struct wlr_seat           fake_seat      = {};
805 +
    struct wlr_keyboard_group keyboard_group = {};
806 +
    keyboard_group_t          group          = {};
807 +
    struct wl_event_loop     *loop;
808 +
    xkb_keysym_t              symbol = XKB_KEY_j;
809 +
810 +
    seat   = &fake_seat;
811 +
    selmon = nullptr;
812 +
    wl_list_init(&fstack);
813 +
    assert(key_binding(MOD, XKB_KEY_1, false) == 1);
814 +
    assert(key_binding(MOD | WLR_MODIFIER_CAPS, XKB_KEY_1, false) == 1);
815 +
    assert(key_binding(MOD, XKB_KEY_1, true) == 0);
816 +
    assert(key_binding(MOD, XKB_KEY_j, true) == 2);
817 +
    assert(key_binding(0, XKB_KEY_NoSymbol, 0) == 0);
818 +
819 +
    loop = wl_event_loop_create();
820 +
    assert(loop);
821 +
    group.wlr_group                          = &keyboard_group;
822 +
    group.nsyms                              = 1;
823 +
    group.keysyms                            = &symbol;
824 +
    group.mods                               = MOD;
825 +
    keyboard_group.keyboard.repeat_info.rate = 25;
826 +
    group.key_repeat_source                  = wl_event_loop_add_timer(loop, key_repeat, &group);
827 +
    assert(group.key_repeat_source);
828 +
    assert(key_repeat(&group) == 0);
829 +
    wl_event_source_remove(group.key_repeat_source);
830 +
    wl_event_loop_destroy(loop);
831 +
}
832 +
833 +
/* Name one compositor-internal unit test callable by the Python runner. */
834 +
typedef struct {
835 +
    const char *name;
836 +
    void (*run)(void);
837 +
} internal_test_t;
838 +
839 +
/* Run the requested compositor-internal unit test. */
840 +
int main(int argc, char **argv) {
841 +
    static const internal_test_t tests[] = {
842 +
        { "geometry", test_geometry },
843 +
        { "state", test_state },
844 +
        { "parsing", test_parsing },
845 +
        { "layouts", test_layouts },
846 +
        { "utilities", test_utilities },
847 +
        { "pools", test_pools },
848 +
        { "workspaces", test_workspace_queries },
849 +
        { "paths-and-borders", test_paths_and_border_policy },
850 +
        { "rules", test_rule_application },
851 +
        { "focus", test_focus_queries },
852 +
        { "window-state", test_window_state_storage },
853 +
        { "child-reaping", test_child_reaping },
854 +
        { "spawn-tracking", test_spawn_tracking },
855 +
        { "commands-without-clients", test_commands_without_clients },
856 +
        { "small-helpers", test_small_helpers },
857 +
        { "client-accessors", test_client_accessors },
858 +
        { "autostart", test_autostart_execution },
859 +
        { "pointer-gestures", test_pointer_gestures },
860 +
        { "input-method", test_input_method_lifecycle },
861 +
        { "input-early-paths", test_input_early_paths },
862 +
        { "command-line", test_command_line_errors },
863 +
        { "pointer-and-decoration", test_pointer_and_decoration_lifecycle },
864 +
        { "keybindings-and-repeat", test_keybindings_and_repeat },
865 +
    };
866 +
867 +
    if (argc == 2 && !strcmp(argv[1], "--list")) {
868 +
        for (size_t i = 0; i < LENGTH(tests); i++)
869 +
            puts(tests[i].name);
870 +
        return 0;
871 +
    }
872 +
    if (argc != 2) {
873 +
        fprintf(stderr, "usage: unit --list|TEST\n");
874 +
        return 2;
875 +
    }
876 +
    for (size_t i = 0; i < LENGTH(tests); i++) {
877 +
        if (!strcmp(argv[1], tests[i].name)) {
878 +
            tests[i].run();
879 +
            return 0;
880 +
        }
881 +
    }
882 +
    fprintf(stderr, "unknown internal test: %s\n", argv[1]);
883 +
    return 2;
884 +
}