3 Testing with the Test Compositor
dxtr edited this page 2026-10-18 15:09:28 +02:00

Testing with the Test Compositor

cl-wl/test-compositor is a minimal compositor that runs inside your test process, built on libwayland-server. It is connected to a client display over a socketpair. It:

  • advertises the globals a test asks for, at the versions it asks for;
  • creates a resource for every object the client creates;
  • records every request it receives, with its arguments as Lisp values;
  • sends the events and protocol errors the test tells it to;
  • runs only when told to, so a test is deterministic.

It implements no protocol behaviour of its own. It does not answer pings, send configures or release buffers unless the test posts those events. That is the point: the test decides exactly what the client sees. cl-wl's own suite and cl-windowing's Wayland tests are written this way.

(asdf:load-system :cl-wl/test-compositor)

It needs libwayland-server (Debian/Ubuntu: libwayland-server0) and cl-threads.

A first test

(defpackage #:my-tests
  (:use #:cl #:cl-wl/wayland #:cl-wl/xdg-shell #:cl-wl/test-compositor)
  (:local-nicknames (#:wl #:cl-wl)))
(in-package #:my-tests)

(with-test-compositor (compositor display (wl-compositor 6))
  (let* ((wl-compositor (bind-global compositor display 'wl-compositor 6))
         (surface (wl-compositor-create-surface wl-compositor)))
    (wl-surface-set-buffer-transform surface :|90|)
    (wl-surface-commit surface)
    (pump compositor display)
    (list (mapcar #'request-name (compositor-requests compositor))
          (compositor-requests compositor "set_buffer_transform"))))
;; => (("bind" "create_surface" "set_buffer_transform" "commit")
;;     (#<REQUEST set_buffer_transform :|90|>))

with-test-compositor takes the variables for the compositor and the client display, followed by the globals to advertise as unevaluated (class version) pairs. Both are destroyed when the body is left.

Moving messages: pump

Nothing moves until you say so. pump:

  1. flushes the client's requests;
  2. lets the compositor handle them (compositor-dispatch);
  3. dispatches the client's events with a timeout of 0;

and repeats while a round delivered events or brought requests. A request that a handler sends in answer to an event, such as xdg-surface-ack-configure from a :configure handler, therefore arrives in the same pump. It returns the number of events delivered. The compositor sends no events on its own, so pump always ends.

(with-test-compositor (compositor display (wl-compositor 6) (xdg-wm-base 6))
  (let* ((surface (wl-compositor-create-surface
                   (bind-global compositor display 'wl-compositor 6)))
         (xdg-surface (xdg-wm-base-get-xdg-surface
                       (bind-global compositor display 'xdg-wm-base 6) surface))
         (toplevel (xdg-surface-get-toplevel xdg-surface))
         (seen '()))
    (wl:set-event-handlers toplevel
      :configure (lambda (toplevel width height states)
                   (declare (ignore toplevel))
                   (push (list width height states) seen)))
    (wl:set-event-handlers xdg-surface
      :configure (lambda (xdg-surface serial)
                   (xdg-surface-ack-configure xdg-surface serial)))
    (pump compositor display)
    (post-event compositor (compositor-last-created compositor "get_toplevel")
                :configure 640 480 '(:maximized :activated))
    (post-event compositor (compositor-last-created compositor "get_xdg_surface")
                :configure 42)
    (pump compositor display)
    (list seen
          (mapcar #'request-arguments (compositor-requests compositor "ack_configure"))
          (compositor-errors compositor))))
;; => (((640 480 (:MAXIMIZED :ACTIVATED))) ((42)) NIL)

compositor-dispatch runs only the compositor's half, for tests that interleave the two ends themselves.

Binding: bind-global

cl-wl:bind blocks until the compositor has answered, which a compositor that only runs when pumped never does. bind-global runs the compositor on a thread while it binds, then pumps:

(bind-global compositor display 'wl-seat 7)    ; => #<WL-SEAT id 3 v7>

For other calls that wait for an answer, such as cl-wl:roundtrip, cl-wl:follow-globals or code under test that binds, wrap them in with-compositor-thread:

(with-compositor-thread (compositor)
  (my-client:initialize display))   ; may BIND and ROUNDTRIP freely

Inside with-compositor-thread the body must not touch the compositor itself, because libwayland-server is not thread-safe.

Resources

A resource is the compositor's side of one client object. It is the same object for as long as it lives, so resources compare with eq:

Reader Value
resource-class the proxy class of its interface, such as wl-surface
resource-version its version
resource-live-p false once the client or the compositor destroyed it

A resource dies when the client sends a destructor request, when the compositor sends a destructor event on it (such as wl_callback's :done), or when the client is disconnected.

What the client sent: requests

(compositor-requests compositor &optional name) returns the requests received, oldest first, only those named name if it is given:

Reader Value
request-name the protocol's name, such as "set_title", or "bind"
request-target the resource it was sent to; NIL for a bind
request-arguments its arguments in wire order, as cl-wl would deliver them
request-created the resource it created, or NIL

Arguments arrive as Lisp values: enum values as keywords (:xrgb8888, :|90|), fixed numbers as exact rationals, strings, objects as resources, arrays of enum values as keyword lists, other 32-bit arrays as vectors, byte arrays as octet vectors, and fds as integers. A new object appears both among the arguments and as request-created. A bind's arguments are (class version).

Two shortcuts find the resources of the newest request of a name, and signal wl:wl-error, saying so, if there was none:

  • (compositor-last-created compositor "get_toplevel") returns the resource that request created; for "bind", the global bound.
  • (compositor-last-target compositor "commit") returns the resource that request was sent to.

clear-requests forgets what has been recorded so far, which is useful between the setup and the part a test checks.

Sending events: post-event

(post-event compositor toplevel :configure 640 480 '(:maximized :activated))
(post-event compositor seat :capabilities '(:pointer :keyboard))
(post-event compositor pointer :motion 100 5/2 -1)

Values are given as cl-wl delivers them: enum values as keywords or keyword lists (integers are sent as they are), fixed numbers as reals, strings as strings, objects as resources, arrays of enum values as keyword lists, and arrays of integers as sequences. An octet vector is sent as raw bytes, for a string or an array, which lets a test send malformed data such as invalid UTF-8. A wrong number of values, or an event the interface lacks, signals wl:wl-error naming what is expected, as does posting on a resource that has been destroyed:

The :NAME event of #<RESOURCE wl_seat v7> takes 1 value: name, not 0.
#<RESOURCE wl_seat v7> has no :NOPE event.  Its events are :CAPABILITIES, :NAME.

After a destructor event, such as wl_callback's :done, the resource is destroyed, as a real compositor destroys it.

For an event that creates an object, such as wl_data_device's :data-offer, (create-resource compositor parent class) makes the compositor-side object to pass for its new_id.

Globals that come and go

(let ((output (add-global compositor 'wl-output 4)))   ; a monitor plugged in
  (pump compositor display)
  …
  (remove-global compositor output)                    ; and unplugged
  (pump compositor display))

The client hears of each change at its next dispatch after pump or compositor-dispatch. Use this to test code built on follow-globals.

Failures

;; A protocol error: the client's next dispatch signals COMPOSITOR-ERROR.
(post-error compositor (compositor-last-created compositor "create_surface")
            (wl:enum-value 'wl-surface-error :invalid-size) "invalid size")
(handler-case (pump compositor display)
  (wl:compositor-error (e)
    (list (wl:compositor-error-interface e)                              ; "wl_surface"
          (wl:enum-keywords 'wl-surface-error (wl:compositor-error-code e)))))  ; :INVALID-SIZE

;; A dropped client: the next read signals CONNECTION-ERROR.
(disconnect-client compositor)
(handler-case (wl:dispatch display :timeout 1)
  (wl:connection-error (e) (princ-to-string e)))
;; => "The Wayland connection failed: Broken pipe"

libwayland prints the protocol error's message to standard error, but it does not pass it on to the client.

Checking the compositor itself

Errors inside the compositor's callbacks, while it handles a request, cannot unwind through libwayland. They are recorded instead. Every test should check that (compositor-errors compositor) is empty.

Without the macro

(multiple-value-bind (compositor display)
    (make-test-compositor '((wl-compositor 6) (wl-shm 1)))
  (unwind-protect (run-tests compositor display)
    (wl:disconnect display)               ; the client first
    (destroy-test-compositor compositor)))

Reference

Symbol What it does
test-compositor The compositor's type.
make-test-compositor globals Makes a compositor and a connected display; returns both.
destroy-test-compositor compositor Destroys it. Disconnect the client first.
with-test-compositor (compositor display &rest globals) &body Both, destroyed on exit.
pump compositor display Moves messages both ways until nothing moves. Returns events delivered.
compositor-dispatch compositor &optional timeout-ms Lets the compositor handle what has arrived and send its events.
with-compositor-thread (compositor) &body Runs the compositor on a thread during the body.
bind-global compositor display class version Binds a global and returns the proxy.
add-global compositor class version Advertises a global; returns it for remove-global.
remove-global compositor global Withdraws a global.
disconnect-client compositor Drops the client.
resource, resource-class, resource-version, resource-live-p The compositor's side of a client object.
create-resource compositor parent class A compositor-side object, for events that create one.
request, request-name, request-target, request-arguments, request-created A request received.
compositor-requests compositor &optional name The requests received, oldest first.
clear-requests compositor Forgets them.
compositor-last-target compositor name The resource the newest name request was sent to.
compositor-last-created compositor name The resource the newest name request created.
compositor-errors compositor Errors raised in the compositor's callbacks, newest first.
post-event compositor resource event &rest values Sends an event.
post-error compositor resource code message Raises a protocol error.

Thread rules: a compositor and its client display are used from one thread, except inside with-compositor-thread, where the compositor runs on a thread of its own and must not be touched.

Limits: it is Linux-only, because it uses socketpair with Linux constants, and it serves a single client.