git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commitaaeab43366243ce00235bd914f93815d7fdb1398
parentfdd1209b6a
authorLucas Galante <[email protected]>
date2026-08-30 19:37
test: behavioral test for the Escape-closes-status-menus arm

New verify/ directory: behavioral tests that a unit test cannot express
because the behavior needs a running compositor. Drivers start a private
cce-shadow instance, drive it with real Wayland clients, and assert on what
the compositor observably does.

verify/clients/ is the shared client crate — deliberately not a workspace
member (severed with an empty [workspace], own target/, invisible to
ccebuild), built on demand by the drivers:

- vkey injects key events through zwp_virtual_keyboard_v1 (fdd1209 is what
  makes this reach the keyboard stack at all), with mod:MASK args for held
  modifiers. Promoted from the session scratchpad where its predecessor was
  lost once already.
- status-stub maps a cce-status* toplevel 400px tall — geometrically an open
  in-surface menu, since expanded is defined as thicker than bar_height —
  subscribes to the status socket's dismiss topic, prints one line per push,
  and shrinks to a bar strip on the first one, reacting like the real bar.

verify/escape-dismiss-test composes the two and proves all three gates of
the arm in handle_builtin_binding (7db8c03):

  1. Ctrl+Escape while expanded  -> no dismiss   (modifiers == 0 gate)
  2. plain Escape while expanded -> exactly one dismiss, and the stub's
     shrink is visible in ctl windows             (the dismissal itself)
  3. plain Escape after shrink   -> no dismiss   (any_expanded_status_segment
                                                  gate, now closed)

The wait_for helper takes predicate functions, not test "$(...)" args — a
command substitution in the arguments would be expanded once at call time
and the loop would poll a constant, which cost the first two runs.

Co-Authored-By: Claude Fable 5 <[email protected]>

 CLAUDE.md                             |  28 ++++
 verify/.gitignore                     |   1 +
 verify/clients/Cargo.lock             | 245 ++++++++++++++++++++++++++++++++++
 verify/clients/Cargo.toml             |  25 ++++
 verify/clients/src/bin/status_stub.rs | 244 +++++++++++++++++++++++++++++++++
 verify/clients/src/bin/vkey.rs        | 142 ++++++++++++++++++++
 verify/escape-dismiss-test            |  96 +++++++++++++
 7 files changed, 781 insertions(+)

diff --git a/CLAUDE.md b/CLAUDE.md
index 10bc5b6..dc5122a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -220,6 +220,34 @@ cargo test --lib <name>           # single test by (substring) name
 cargo test --lib config::         # tests in the config module
 ```
 
+### `verify/` — behavioral tests in a shadow session
+
+For behavior the unit tests cannot reach (it needs a running compositor),
+`verify/` holds self-contained test drivers that start a private `cce-shadow`
+instance, drive it with real Wayland clients, and assert on what the
+compositor observably does. `verify/clients/` is the shared client crate —
+deliberately **not** a workspace member (severed with an empty `[workspace]`,
+own `target/`, invisible to ccebuild), built on demand by the drivers:
+
+- **`vkey`** — injects key events through `zwp_virtual_keyboard_v1`
+  (wtype-style; evdev keycodes plus `mod:MASK` args for held modifiers).
+  This exercises the same `KeyboardGroup::handle_group_key` path hardware
+  keys take, so keybindings and builtins fire for injected keys.
+- **`status-stub`** — maps an xdg toplevel with a `cce-status*` app_id 400px
+  tall, which `any_expanded_status_segment` reads as an open in-surface menu
+  (expanded is geometric: thicker than `layout.bar_height`). It subscribes to
+  the status socket's `dismiss` topic, prints one line per push, and shrinks
+  to a bar strip on the first one — reacting the way the real bar does.
+
+`./verify/escape-dismiss-test` composes the two to prove all three gates of
+the Escape-closes-status-menus arm (`handle_builtin_binding`): a chorded
+Escape stays out of the arm, a plain Escape while expanded pushes exactly one
+dismiss (and the stub's shrink is visible in `ctl windows`), and a plain
+Escape with nothing expanded stays quiet. The compositor binary is whatever
+`cce-shadow` resolves (installed first, then `target/release`); extra args
+pass through to `cce-shadow start`, so `--bin ../target/release/cce-fx` pins
+the tree's own build.
+
 ## Build pipeline (`build.rs`)
 
 `build.rs` does a lot before Rust compiles:
diff --git a/verify/.gitignore b/verify/.gitignore
new file mode 100644
index 0000000..8615312
--- /dev/null
+++ b/verify/.gitignore
@@ -0,0 +1 @@
+/clients/target/
diff --git a/verify/clients/Cargo.lock b/verify/clients/Cargo.lock
new file mode 100644
index 0000000..936e008
--- /dev/null
+++ b/verify/clients/Cargo.lock
@@ -0,0 +1,245 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[package]]
+name = "cc"
+version = "1.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cce-verify-clients"
+version = "0.1.0"
+dependencies = [
+ "libc",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-misc",
+ "xkbcommon",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags",
+ "rustix",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "pkg-config",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "xkbcommon"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9"
+dependencies = [
+ "libc",
+ "memmap2",
+ "xkeysym",
+]
+
+[[package]]
+name = "xkeysym"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
diff --git a/verify/clients/Cargo.toml b/verify/clients/Cargo.toml
new file mode 100644
index 0000000..a975aff
--- /dev/null
+++ b/verify/clients/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "cce-verify-clients"
+version = "0.1.0"
+edition = "2021"
+description = "Wayland test clients for behavioral verification in cce-shadow sessions"
+
+[[bin]]
+name = "vkey"
+path = "src/bin/vkey.rs"
+
+[[bin]]
+name = "status-stub"
+path = "src/bin/status_stub.rs"
+
+[dependencies]
+wayland-client = "0.31"
+wayland-protocols = { version = "0.32", features = ["client"] }
+wayland-protocols-misc = { version = "0.3", features = ["client"] }
+xkbcommon = "0.8"
+libc = "0.2"
+
+# Deliberately NOT a member of the cce workspace: these are test clients built
+# on demand by verify/ scripts, invisible to ccebuild and `cargo build` at the
+# workspace root. The empty table severs workspace resolution.
+[workspace]
diff --git a/verify/clients/src/bin/status_stub.rs b/verify/clients/src/bin/status_stub.rs
new file mode 100644
index 0000000..93dbd0e
--- /dev/null
+++ b/verify/clients/src/bin/status_stub.rs
@@ -0,0 +1,244 @@
+// status-stub — a fake status segment for behavioral tests.
+//
+// Maps an xdg toplevel with app_id "cce-status-teststub", which the compositor
+// roles as a StatusBar (any "cce-status*" app_id) and therefore sizes from the
+// client's own content. It maps EXPANDED_H tall — thicker than any sane
+// bar_height, so `any_expanded_status_segment` reads it as an open in-surface
+// menu. It subscribes to the `dismiss` topic on the compositor's status socket
+// and reacts the way the real bar does: on the first dismiss push it shrinks
+// to SHRUNK_H (back to a bar strip, menu closed), printing one
+// "dismiss <payload>" line to stdout per push so a driver script can count
+// them. Runs until killed (the cce-shadow stop sweep finds it by HOME).
+
+use std::io::{BufRead, Write};
+use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd};
+use std::sync::mpsc;
+use wayland_client::{
+    delegate_noop,
+    protocol::{wl_buffer, wl_compositor, wl_registry, wl_shm, wl_shm_pool, wl_surface},
+    Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols::xdg::shell::client::{xdg_surface, xdg_toplevel, xdg_wm_base};
+
+const WIDTH: i32 = 360;
+const EXPANDED_H: i32 = 400; // > bar_height: reads as an open menu
+const SHRUNK_H: i32 = 8; // <= bar_height (default 24): plain bar strip
+
+#[derive(Default)]
+struct State {
+    compositor: Option<wl_compositor::WlCompositor>,
+    shm: Option<wl_shm::WlShm>,
+    wm_base: Option<xdg_wm_base::XdgWmBase>,
+    configured: bool,
+    closed: bool,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+    fn event(
+        state: &mut Self,
+        registry: &wl_registry::WlRegistry,
+        event: wl_registry::Event,
+        _: &(),
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+    ) {
+        if let wl_registry::Event::Global { name, interface, version } = event {
+            match interface.as_str() {
+                "wl_compositor" => {
+                    state.compositor = Some(
+                        registry.bind::<wl_compositor::WlCompositor, _, _>(name, version.min(4), qh, ()),
+                    );
+                }
+                "wl_shm" => {
+                    state.shm = Some(registry.bind::<wl_shm::WlShm, _, _>(name, 1, qh, ()));
+                }
+                "xdg_wm_base" => {
+                    state.wm_base =
+                        Some(registry.bind::<xdg_wm_base::XdgWmBase, _, _>(name, 1, qh, ()));
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+impl Dispatch<xdg_wm_base::XdgWmBase, ()> for State {
+    fn event(
+        _: &mut Self,
+        wm_base: &xdg_wm_base::XdgWmBase,
+        event: xdg_wm_base::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        if let xdg_wm_base::Event::Ping { serial } = event {
+            wm_base.pong(serial);
+        }
+    }
+}
+
+impl Dispatch<xdg_surface::XdgSurface, ()> for State {
+    fn event(
+        state: &mut Self,
+        xdg_surface: &xdg_surface::XdgSurface,
+        event: xdg_surface::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        if let xdg_surface::Event::Configure { serial } = event {
+            xdg_surface.ack_configure(serial);
+            state.configured = true;
+        }
+    }
+}
+
+impl Dispatch<xdg_toplevel::XdgToplevel, ()> for State {
+    fn event(
+        state: &mut Self,
+        _: &xdg_toplevel::XdgToplevel,
+        event: xdg_toplevel::Event,
+        _: &(),
+        _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+        // Configure sizes are ignored: a Status window sizes itself.
+        if let xdg_toplevel::Event::Close = event {
+            state.closed = true;
+        }
+    }
+}
+
+delegate_noop!(State: ignore wl_compositor::WlCompositor);
+delegate_noop!(State: ignore wl_shm::WlShm);
+delegate_noop!(State: ignore wl_shm_pool::WlShmPool);
+delegate_noop!(State: ignore wl_buffer::WlBuffer);
+delegate_noop!(State: ignore wl_surface::WlSurface);
+
+/// One shm pool big enough for the expanded buffer; both buffers share it
+/// (contents are a solid fill, overlap does not matter).
+fn make_pool_fd() -> OwnedFd {
+    let size = (WIDTH * 4 * EXPANDED_H) as u64;
+    let fd = unsafe { libc::memfd_create(b"status-stub\0".as_ptr() as *const _, 0) };
+    assert!(fd >= 0, "memfd_create failed");
+    let file = unsafe { std::fs::File::from_raw_fd(fd) };
+    file.set_len(size).unwrap();
+    // Solid opaque slate; any visible pixels will do.
+    let mmapped = unsafe {
+        libc::mmap(
+            std::ptr::null_mut(),
+            size as usize,
+            libc::PROT_WRITE,
+            libc::MAP_SHARED,
+            file.as_raw_fd(),
+            0,
+        )
+    };
+    assert!(mmapped != libc::MAP_FAILED, "mmap failed");
+    unsafe {
+        let px = mmapped as *mut u32;
+        for i in 0..(WIDTH * EXPANDED_H) as usize {
+            *px.add(i) = 0xff30_3a4a;
+        }
+        libc::munmap(mmapped, size as usize);
+    }
+    OwnedFd::from(file)
+}
+
+fn spawn_dismiss_listener(tx: mpsc::Sender<()>) {
+    std::thread::spawn(move || {
+        let display = std::env::var("WAYLAND_DISPLAY").expect("WAYLAND_DISPLAY not set");
+        let path = format!("/tmp/cce-status-interface-{}.sock", display);
+        let mut stream = None;
+        for _ in 0..50 {
+            match std::os::unix::net::UnixStream::connect(&path) {
+                Ok(s) => {
+                    stream = Some(s);
+                    break;
+                }
+                Err(_) => std::thread::sleep(std::time::Duration::from_millis(100)),
+            }
+        }
+        let mut stream = stream.expect("could not connect to status socket");
+        stream.write_all(b"dismiss\n").unwrap();
+        let reader = std::io::BufReader::new(stream);
+        for line in reader.lines() {
+            let line = match line {
+                Ok(l) => l,
+                Err(_) => break,
+            };
+            println!("dismiss {}", line);
+            std::io::stdout().flush().ok();
+            let _ = tx.send(());
+        }
+    });
+}
+
+fn main() {
+    let conn = Connection::connect_to_env().expect("connect to wayland display");
+    let display = conn.display();
+    let mut queue = conn.new_event_queue();
+    let qh = queue.handle();
+    let _registry = display.get_registry(&qh, ());
+
+    let mut state = State::default();
+    queue.roundtrip(&mut state).unwrap();
+
+    let compositor = state.compositor.clone().expect("no wl_compositor");
+    let shm = state.shm.clone().expect("no wl_shm");
+    let wm_base = state.wm_base.clone().expect("no xdg_wm_base");
+
+    let surface = compositor.create_surface(&qh, ());
+    let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ());
+    let toplevel = xdg_surface.get_toplevel(&qh, ());
+    toplevel.set_app_id("cce-status-teststub".into());
+    toplevel.set_title("escape-dismiss-test".into());
+    surface.commit();
+
+    // First configure before the first buffer, per xdg-shell.
+    while !state.configured {
+        queue.blocking_dispatch(&mut state).unwrap();
+    }
+
+    let pool_fd = make_pool_fd();
+    let pool = shm.create_pool(pool_fd.as_fd(), WIDTH * 4 * EXPANDED_H, &qh, ());
+    let tall = pool.create_buffer(
+        0, WIDTH, EXPANDED_H, WIDTH * 4, wl_shm::Format::Argb8888, &qh, (),
+    );
+    let short = pool.create_buffer(
+        0, WIDTH, SHRUNK_H, WIDTH * 4, wl_shm::Format::Argb8888, &qh, (),
+    );
+
+    surface.attach(Some(&tall), 0, 0);
+    surface.damage_buffer(0, 0, WIDTH, EXPANDED_H);
+    surface.commit();
+
+    let (tx, rx) = mpsc::channel();
+    spawn_dismiss_listener(tx);
+
+    let mut shrunk = false;
+    while !state.closed {
+        conn.flush().unwrap();
+        if let Some(guard) = conn.prepare_read() {
+            let mut pfd = libc::pollfd {
+                fd: guard.connection_fd().as_raw_fd(),
+                events: libc::POLLIN,
+                revents: 0,
+            };
+            let n = unsafe { libc::poll(&mut pfd, 1, 100) };
+            if n > 0 && (pfd.revents & libc::POLLIN) != 0 {
+                let _ = guard.read();
+            } else {
+                drop(guard);
+            }
+        }
+        queue.dispatch_pending(&mut state).unwrap();
+
+        if !shrunk && rx.try_recv().is_ok() {
+            surface.attach(Some(&short), 0, 0);
+            surface.damage_buffer(0, 0, WIDTH, SHRUNK_H);
+            surface.commit();
+            shrunk = true;
+        }
+    }
+}
diff --git a/verify/clients/src/bin/vkey.rs b/verify/clients/src/bin/vkey.rs
new file mode 100644
index 0000000..6213900
--- /dev/null
+++ b/verify/clients/src/bin/vkey.rs
@@ -0,0 +1,142 @@
+// vkey — inject key events through zwp_virtual_keyboard_v1 (wtype-style),
+// for exercising the compositor's keyboard stack in cce-shadow sessions.
+//
+// Usage: vkey <keycode|mod:MASK> ...
+//   keycode   evdev code, pressed then released (h=35 e=18 l=38 o=24, 1=Escape)
+//   mod:MASK  set held modifiers for the keys that follow (xkb depressed mask
+//             under the us keymap: shift=1 ctrl=4 alt=8 super=64); cleared on exit
+
+use std::io::Write;
+use std::os::fd::{BorrowedFd, FromRawFd, OwnedFd};
+use wayland_client::{
+    protocol::{wl_registry, wl_seat},
+    Connection, Dispatch, QueueHandle,
+};
+use wayland_protocols_misc::zwp_virtual_keyboard_v1::client::{
+    zwp_virtual_keyboard_manager_v1, zwp_virtual_keyboard_v1,
+};
+
+#[derive(Default)]
+struct State {
+    seat: Option<wl_seat::WlSeat>,
+    manager: Option<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1>,
+}
+
+impl Dispatch<wl_registry::WlRegistry, ()> for State {
+    fn event(
+        state: &mut Self,
+        registry: &wl_registry::WlRegistry,
+        event: wl_registry::Event,
+        _: &(),
+        _: &Connection,
+        qh: &QueueHandle<Self>,
+    ) {
+        if let wl_registry::Event::Global {
+            name, interface, version,
+        } = event
+        {
+            match interface.as_str() {
+                "wl_seat" => {
+                    state.seat =
+                        Some(registry.bind::<wl_seat::WlSeat, _, _>(name, version.min(7), qh, ()));
+                }
+                "zwp_virtual_keyboard_manager_v1" => {
+                    state.manager = Some(
+                        registry
+                            .bind::<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, _, _>(
+                                name, 1, qh, (),
+                            ),
+                    );
+                }
+                _ => {}
+            }
+        }
+    }
+}
+
+impl Dispatch<wl_seat::WlSeat, ()> for State {
+    fn event(
+        _: &mut Self, _: &wl_seat::WlSeat, _: wl_seat::Event, _: &(), _: &Connection,
+        _: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+impl Dispatch<zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1, ()> for State {
+    fn event(
+        _: &mut Self, _: &zwp_virtual_keyboard_manager_v1::ZwpVirtualKeyboardManagerV1,
+        _: zwp_virtual_keyboard_manager_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+impl Dispatch<zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1, ()> for State {
+    fn event(
+        _: &mut Self, _: &zwp_virtual_keyboard_v1::ZwpVirtualKeyboardV1,
+        _: zwp_virtual_keyboard_v1::Event, _: &(), _: &Connection, _: &QueueHandle<Self>,
+    ) {
+    }
+}
+
+fn keymap_fd() -> (OwnedFd, u32) {
+    let ctx = xkbcommon::xkb::Context::new(xkbcommon::xkb::CONTEXT_NO_FLAGS);
+    let keymap = xkbcommon::xkb::Keymap::new_from_names(
+        &ctx, "", "", "us", "", None, xkbcommon::xkb::KEYMAP_COMPILE_NO_FLAGS,
+    )
+    .expect("compile keymap");
+    let string = keymap.get_as_string(xkbcommon::xkb::KEYMAP_FORMAT_TEXT_V1);
+
+    let fd = unsafe { libc::memfd_create(b"vkey-keymap\0".as_ptr() as *const _, 0) };
+    assert!(fd >= 0, "memfd_create failed");
+    let mut file = unsafe { std::fs::File::from_raw_fd(fd) };
+    file.write_all(string.as_bytes()).unwrap();
+    file.write_all(b"\0").unwrap();
+    let size = string.len() as u32 + 1;
+    (OwnedFd::from(file), size)
+}
+
+fn main() {
+    // args: keycodes, or "mod:<depressed-mask>" to change held modifiers
+    let args: Vec<String> = std::env::args().skip(1).collect();
+    assert!(!args.is_empty(), "usage: vkey <keycode|mod:MASK> ...");
+
+    let conn = Connection::connect_to_env().expect("connect to wayland display");
+    let display = conn.display();
+    let mut queue = conn.new_event_queue();
+    let qh = queue.handle();
+    let _registry = display.get_registry(&qh, ());
+
+    let mut state = State::default();
+    queue.roundtrip(&mut state).unwrap();
+
+    let seat = state.seat.clone().expect("no wl_seat advertised");
+    let manager = state
+        .manager
+        .clone()
+        .expect("no zwp_virtual_keyboard_manager_v1 advertised");
+
+    let vk = manager.create_virtual_keyboard(&seat, &qh, ());
+
+    let (fd, size) = keymap_fd();
+    vk.keymap(1 /* XKB_V1 */, unsafe { BorrowedFd::borrow_raw(std::os::fd::AsRawFd::as_raw_fd(&fd)) }, size);
+    queue.roundtrip(&mut state).unwrap();
+
+    let mut t = 0u32;
+    for arg in &args {
+        if let Some(mask) = arg.strip_prefix("mod:") {
+            let depressed: u32 = mask.parse().expect("mod mask must be a number");
+            vk.modifiers(depressed, 0, 0, 0);
+        } else {
+            let keycode: u32 = arg.parse().expect("keycode must be a number");
+            vk.key(t, keycode, 1); // pressed
+            vk.key(t + 5, keycode, 0); // released
+            t += 10;
+        }
+    }
+    vk.modifiers(0, 0, 0, 0);
+    queue.roundtrip(&mut state).unwrap();
+
+    vk.destroy();
+    queue.roundtrip(&mut state).unwrap();
+    println!("sent {} event(s)", args.len());
+}
diff --git a/verify/escape-dismiss-test b/verify/escape-dismiss-test
new file mode 100755
index 0000000..3409f00
--- /dev/null
+++ b/verify/escape-dismiss-test
@@ -0,0 +1,96 @@
+#!/usr/bin/env bash
+# escape-dismiss-test — behavioral test for the Escape-closes-status-menus arm
+# in keyboard_group.rs::handle_builtin_binding (7db8c03), driven end to end
+# through the virtual-keyboard path (fdd1209).
+#
+# Setup: a private cce-shadow instance, with status-stub mapped as a status
+# segment 400px tall — geometrically "expanded", i.e. an open in-surface menu.
+# The stub subscribes to the `dismiss` topic and shrinks to a bar strip on the
+# first push, printing one line per push. vkey injects Escape through
+# zwp_virtual_keyboard_v1. Three assertions, one per gate of the arm:
+#
+#   1. Ctrl+Escape while expanded  -> no dismiss   (modifiers == 0 gate)
+#   2. plain Escape while expanded -> one dismiss, stub shrinks below
+#                                     bar_height   (the dismissal itself)
+#   3. plain Escape after shrink   -> no dismiss   (any_expanded_status_segment
+#                                                   gate, now closed)
+#
+# The compositor binary is whatever cce-shadow resolves (installed first, then
+# the workspace target/release); extra args are passed to `cce-shadow start`,
+# so `escape-dismiss-test --bin path/to/cce-fx` pins one explicitly.
+
+set -euo pipefail
+
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(dirname "$here")
+shadow="$repo/scripts/cce-shadow"
+out=$(mktemp -d)
+inst=""
+
+cleanup() {
+    [ -n "$inst" ] && "$shadow" --instance "$inst" stop >/dev/null 2>&1 || true
+    rm -rf "$out"
+}
+trap cleanup EXIT
+
+fail() {
+    echo "FAIL: $*" >&2
+    echo "--- stub log ---" >&2
+    cat "$out/stub.log" >&2 || true
+    echo "--- compositor log tail ---" >&2
+    [ -n "$inst" ] && "$shadow" --instance "$inst" logs 2>/dev/null | tail -20 >&2 || true
+    exit 1
+}
+
+echo "==> building test clients"
+cargo build --quiet --manifest-path "$here/clients/Cargo.toml"
+bin="$here/clients/target/debug"
+
+echo "==> starting shadow instance"
+inst=$("$shadow" start --new "$@" 2>&1 | sed -n "s/.*claimed instance '\([^']*\)'.*/\1/p")
+[ -n "$inst" ] || fail "could not claim a shadow instance"
+sc() { "$shadow" --instance "$inst" "$@"; }
+
+stub_h() {
+    sc ctl windows --json 2>/dev/null \
+        | jq -r 'select(.app_id == "cce-status-teststub") | .h' | head -1
+}
+dismisses() { grep -c '^dismiss' "$out/stub.log" 2>/dev/null || true; }
+
+# Predicates for wait_for. They must be functions, not `test "$(...)" = x`
+# inline: a command substitution in wait_for's arguments would be expanded
+# once at call time, and the loop would poll a constant.
+stub_h_is() { [ "$(stub_h)" = "$1" ]; }
+dismiss_count_is() { [ "$(dismisses)" = "$1" ]; }
+
+wait_for() { # <description> <timeout-seconds> <predicate...>
+    local desc=$1 timeout=$2 i=0
+    shift 2
+    until "$@"; do
+        i=$((i + 1))
+        [ "$i" -ge $((timeout * 5)) ] && fail "timed out waiting for $desc"
+        sleep 0.2
+    done
+}
+
+echo "==> mapping expanded status stub"
+sc run "$bin/status-stub" >"$out/stub.log" 2>&1 &
+wait_for "stub mapped expanded (h=400)" 10 stub_h_is 400
+
+echo "==> gate 1: Ctrl+Escape while expanded must not dismiss"
+sc run "$bin/vkey" mod:4 1 mod:0 >/dev/null
+sleep 1
+[ "$(dismisses)" = 0 ] || fail "chorded Escape dismissed the menu (got $(dismisses) dismiss lines)"
+[ "$(stub_h)" = 400 ] || fail "stub shrank on chorded Escape"
+
+echo "==> gate 2: plain Escape while expanded must dismiss exactly once"
+sc run "$bin/vkey" 1 >/dev/null
+wait_for "dismiss push to reach the stub" 5 dismiss_count_is 1
+wait_for "stub to shrink below bar_height" 5 stub_h_is 8
+
+echo "==> gate 3: plain Escape with no expanded segment must stay quiet"
+sc run "$bin/vkey" 1 >/dev/null
+sleep 1
+[ "$(dismisses)" = 1 ] || fail "Escape dismissed with no menu open (got $(dismisses) dismiss lines)"
+
+echo "PASS: all three gates of the Escape-dismiss arm behave"