qs-test
Live-test the user's running Quickshell (bidshell) like a real user - glide the real mouse, click and type, find items by QML id / type / text through Qt's QML debugger, check state, take cropped scre
SKILL.md.config/quickshell/.claude/skills/qs-test/SKILL.md
GitHubqs-test
Test the live shell the way the user uses it: real pointer and keyboard events, both monitors, every state looked at, every log line read. The goal is to find bugs before the user does; a missed bug costs far more than extra screenshots or tokens, so look at things instead of assuming.
Everything goes through scripts/qstest.py (run it with python3). scripts/qmldbg.py is its client
for Qt's QML debug protocol; you do not call it directly.
How it works (so you can reason about failures)
startrestarts quickshell asqs -d --debug 47777. That debug port gives the full live object tree (QML ids, types, file:line) and lets JS run on any object. The shell itself contains no test code.- The port listens on every network interface: anyone on the LAN could run code in the shell while it
is open. So
endrestarts qs without it, andstartarms a watchdog that does the same after 30 min. Always finish a session withend, also after a failure. - The mouse moves like a hand (ease-out glide, ~0.5 s, real
ydotoolevents), because instant jumps skip hover and motion events that real bugs depend on. Keys:wtypefor text and Esc into shell surfaces,ydotoolfor Hyprland keybinds (wtype cannot trigger compositor binds). ydotooldruns only betweenstartandend(and the watchdog).- Hyprland asks a permission for ydotoold's virtual keyboard and for
grim's screencopy, and keeps the answer until it exits.permsopens both prompts before the test;startrefuses untilpermsran in this Hyprland session. Afterperms, tell the user to allow both and wait for them to say "gata" beforestart: a prompt answered mid-test means the user takes the mouse and the guard stops the test. - A guard stops any move if the cursor is not where the last move left it: the user took the mouse.
Stop, tell the user, and wait for them before
startagain. startplaysassets/testing.mp3(the user's chosen sound) to the end before anything moves, so the user knows to let go of the mouse (falls back to a notification if it cannot play).
Session
# a function, not Q="python3 ...": zsh does not word-split variables
Q() { python3 /home/bida/dotfiles/.config/quickshell/.claude/skills/qs-test/scripts/qstest.py "$@"; }
Q perms # new Hyprland session only (start says so): then wait for the user's "gata"
Q start # sound, restart qs with the debug port, arm guard + watchdog
Q test "sidebar vpn row" # one test = one behaviour; marks the log
...steps...
Q done # all log lines this test made
Q end # restart qs normally (closes the port)
Run the steps as a batch (one step per line, as you would type them after Q): one Bash call and one
process for a whole group of tests. A step takes about 50 ms plus its glide; the slow part is your own
turns between calls, so put as many steps as you can in one batch and look at one sheet:
Q batch --keep-going --sheet <<'STEPS'
test calendar popout
click type:ClockButton@DP-3
wait type:ClockButton@DP-3 calendar.visible
shot type:CalendarPanelContent@DP-3
key Escape
done
STEPS
A batch stops at the first FAIL, or goes on with --keep-going; it always stops when the guard stops it.
--sheet joins all shots of the batch into one labelled image and prints SHEET path; read that one image,
and crop a shot at full size when you need detail. --open also shows it on the user's screen - only when
they asked, since the viewer can take the focus the next steps need.
Get one case right before running a matrix: check it fully (look at its shots, fix what is wrong), then run the other edges, monitors and settings. A matrix run on a broken case only multiplies the same failure. Between tests, bring the UI back to a known state (Esc, close the sidebar) so a test never depends on the previous one.
Selectors
#fortiRow- QMLid(stable, unique; read the QML file to find ids). Ids come from the debugger's object tree, which does not hold items made by aRepeater/ list delegate: reach those by type or text.type:ToggleTile- every on-screen item of that QML type, found in the visual tree, so list rows work too (inline components use their own name, e.g.type:Tile).eval 'type:X' EXPRruns on the first one.Bluetooth- text /label/title/placeholderText/ tab name, exact;~blue= contains- An optional index picks the Nth match:
click Bluetooth 1 @SCREENlimits a selector to one monitor:click VPN@HDMI-A-1,eval '#sidebarWindow@HDMI-A-1' 'visible'. Per-monitor components (Variants: bar, sidebar, notification popups) exist once per monitor with the same id; without@SCREEN,evaluses the visible instance andfindlists all (with their monitor).
The pointer lands on sub-pixel positions (QML may see 1400.59 where hyprctl cursorpos says 1400), like
a real mouse: compare pixel sizes that come from pointer positions with a tolerance of 1 px.
Only items really on screen match: hidden, zero-size or scrolled out of a clipping parent do not.
Selectors look inside every visible shell window, whatever its type is called (CalendarPopout, TrayMenu).
Tooltips show after a delay (500 ms): wait #vpnTooltip visible before the shot.
Clicks and shots wait until the item stops moving (slide-in animations).
Steps
| step | what |
|---|---|
click SEL [N] [--right], hover SEL [N] |
glide to the item's center, click / only hover |
move X Y [--click] |
glide to global logical coordinates (same space as hyprctl cursorpos) |
wheel N |
scroll N steps where the pointer is (N > 0 up, N < 0 down) |
drag X1 Y1 X2 Y2 |
press, glide holding the left button, release (selections, sliders, drag and drop) |
key Escape, type "text" |
keyboard into the focused shell surface |
bind super+space |
Hyprland keybind with real key events |
wait [#id] EXPR [--timeout=S] |
poll until the JS expression is truthy (use instead of sleep) |
expect [#id] EXPR |
check once; prints FAIL ... <value> |
see SEL / gone SEL |
wait until an item is / is not on screen |
eval [#id or type:T][@SCREEN] EXPR |
print any value, e.g. eval 'Settings.sidebarSelectedTab', eval 'type:SelectionWindow@DP-3' 'regionWidth' |
shot SEL [N] [PAD], shot-screen DP-3 |
cropped / full screenshot, taken once the picture stops changing; prints the path - then Read it |
shot-rect X Y W H |
area in global logical coordinates, for popups and tooltips without a good selector |
find SEL |
list matches (x y w h) |
reload [FILE] |
after editing QML: wait until qs reloaded (touches FILE if not) |
JS runs with ShellRoot as scope (singletons such as Settings, Notifications, Audio work), or with
the object named by #id as scope (its own properties: expanded, text, visible...).
Focus sits on the innermost control: InputField is a wrapper, so activeFocus on it is false while its
inner TextField has focus - check Window.activeFocusItem or the inner field instead.
Every step prints the WARN / ERROR lines it caused (LOG ...), also when it fails. Read them: a step
that "worked" but logged a TypeError is a bug.
Method
- Read the QML first to know ids, types and what each state should look like.
- Every monitor. Run
hyprctl monitorsfirst: each monitor has its own copy of the bar, sidebar and popups, so test on each (@SCREENpicks the copy). A monitor with a fractional scale (1.2, 1.5) finds its own bugs: blurred or 1 px off captures, sub-pixel positions. The sidebar opens on the focused monitor:moveonto a monitor beforebind super+space. Inhyprctl monitors, x/y are logical but width/height are physical pixels: divide them by the scale. - Screenshot every important state and look at each one (open, expanded, each tab, each popup):
clipping, overlap, wrong colors, wrong alignment, cut text only show in images. Text checks
(
expect,wait) add to screenshots; they never replace them. - Check the state you cannot see with
expect/eval(selected tab, expanded, search text). - Read
donefor every test, not only warnings: wrong data (for example a brand lookup returning a wrong domain) shows up in INFO/DEBUG lines. - Watch what the test does to the rest of the desktop. Keys and clicks can reach the app under a
closing popout (for example Esc leaving a video's fullscreen). Compare the windows (
hyprctl clients) before and after, and report any change you did not ask for. - Go for the edges: long names, empty lists, many items, fast repeated open/close, Esc from a focused field, click outside, switching tabs back and forth, theme change while open.
- Report every bug with the step, the screenshot path and the log lines.
Do not
click refuses system toggles (quick-toggle tiles, power buttons, Connect / Disconnect / Clear All) unless
you add --force, which you only do when the user agreed. Text selectors are ambiguous: "Bluetooth" is
both the Bluetooth tile and the Bluetooth tab (find first, then pick the index / type).
- Type real passwords, or click Connect / Disconnect (Wi-Fi, VPN, Bluetooth), power actions (shutdown, reboot, logout, suspend), Clear All, or toggles that change the system (Wi-Fi, Bluetooth, airplane, HDR, DND, idle) without the user's OK. They change the user's machine, not only the UI.
- Lock the screen (you cannot unlock it).
- Judge animations: frames and feel need the user's eyes. List them for the user to check.
Known limits
- Qt's
LIST_OBJECTSanswer is corrupt after reloads (it counts invalid child contexts but does not write them); the client only scans it for the ShellRoot id and gets everything else fromFETCH_OBJECT. - AT-SPI does not work: Quickshell exposes no windows on the accessibility bus.
- Layer windows (bar, popouts) cannot know where the compositor put them; positions come from the one
hyprctl layerssurface with the window's size. xdg popups (bar tooltips,PopupWindow) are not layers: their positions are only Qt's guess, so find them without@SCREENand check them on a screenshot. - Before switching workspaces in a test, look at what is on the target workspace: an app that grabs input (Remmina, a VM, a game) takes the keyboard and the pointer.
- State and screenshots live in
$XDG_RUNTIME_DIR/qstest/;startclears the screenshots.