run-ircx
Build, run, and drive ircx. Use when asked to start ircx, run its tests, build it, take a screenshot of its UI, click through the app, or check that a frontend change works in the running client.
SKILL.mdscripts/run-ircx/SKILL.md
GitHubircx is a Tauri 2 desktop client: a React frontend in a WebKitGTK window, a Rust core behind it. There are two ways to drive it and the first is the one to reach for.
The frontend in headless Chrome, via scripts/run-ircx/driver.mjs,
which starts Vite, launches Chrome, and takes commands on stdin. It needs no npm
package and no display. Almost everything a change touches is reachable that
way: the shell, the command palette, every sheet, themes, the timeline. A
selector reaches an element, text and count answer questions about it, and a
failure points at a line of your code.
The assembled app in a real window, via
scripts/run-ircx/window.mjs, which starts Xvfb, seeds a profile,
launches the app against a server and takes the same kind of commands. Use it
only for what the frontend alone cannot show — a live socket, the delivery
states behind it, and anything the Rust side decides. It is slower, it has no
selectors, and what it tells you is a screenshot.
Prerequisites
google-chrome and the Node version pinned by the project (the driver uses the
built-in WebSocket, so there is nothing to install). Verified Chrome:
google-chrome --version # Google Chrome 151.0.7922.71
window.mjs needs five more, all present here: gcc and libXtst to build the
input helper, Xvfb to put the window somewhere, xprop to tell when it
arrived, and ImageMagick's import to photograph it.
Setup
Each git worktree needs its own node_modules:
npm ci
Run (agent path)
Pipe commands in, read one ok/err line back per command:
mkdir -p /tmp/shots
printf 'goto /\nss /tmp/shots/shell.png\ntext body\nquit\n' \
| node scripts/run-ircx/driver.mjs
Opening a sheet from the command palette — this is the flow most UI work needs:
printf 'goto /
key ctrl+k
type Plugins
wait 400
key Return
wait 600
text [role=dialog]
ss /tmp/shots/plugins.png
key Escape
count [role=dialog]
quit
' | node scripts/run-ircx/driver.mjs
That prints the sheet's text, writes the screenshot, and ends with ok 0
confirming Escape closed it.
With a conversation on screen
By default every invoke rejects, so the app has no networks and no
conversations. Anything needing one — the timeline, the composer, the panes, the
roster, the archive and upload sheets — has to be driven with --seeded, which
answers from seed.mjs instead:
printf 'goto /
wait 1500
click [aria-label="#ircx"]
wait 900
click textarea
type hello
key Return
wait 600
ss /tmp/shots/channel.png
quit
' | node scripts/run-ircx/driver.mjs --seeded
The seed holds one network, #ircx, #rust, a 300-message #long to park in
the middle of, a #band whose sixty lines are one block tall enough to scroll
its own author off the top — which is what draws the sticky author band — and a
query. #ircx's members are picked for the roster's width
arithmetic: a voiced one, an away one, and wallabywombat to reach the ceiling.
A line typed into the composer is kept, so ArrowUp brings it back.
click is not a pointer. It calls el.click(), so nothing moves and no
pointerdown sequence happens — anything driven by pointer events, such as the
pane divider with its setPointerCapture, cannot be worked by it. drag and
dragxy go through the DevTools Protocol instead, which is a real pointer as
far as the page is concerned. dragxy is the one that answers "can this be
hit": a selector always lands dead centre, which is the question nobody is
asking.
This is where the layout defects are. jsdom lays nothing out, so a whole
class of bug — a name truncated by a sub-pixel shortfall, a header wrapping into
a row of fixed height, a scroller clamped before it was measured, a four-pixel
target lying entirely to one side of the line it draws — cannot be caught by
vitest at all. #299, #300, #301 and #307 were each found here, and
none of them had a failing test until a browser had shown the defect first.
Adding a handler to seed.mjs: key it off the Rust parameter names, not the
TypeScript ones. submit_input takes input, not text, and a wrong name
throws inside the seed and reads exactly like a frontend bug. A command with no
handler rejects by name, so a walk that reaches past the seed says so.
| command | what it does |
|---|---|
goto <path> |
navigate, default / |
ss <file> |
screenshot to <file> (PNG) |
eval <expr> |
evaluate, print the JSON result |
text <sel> |
textContent of the first match |
count <sel> |
how many match — 0 is how you assert something closed |
click <sel> |
click the first match |
drag <sel> <dx> <dy> |
press at its centre, move in steps, release |
dragxy <x> <y> <dx> <dy> |
the same from a point, for asking what a target catches |
size <w> <h> |
set the viewport, for what a layout does when narrow |
fill <sel> <value> |
set an input's value the way React notices |
filllabel <label> <value> |
the same, finding the field by its visible label |
type <text> |
insert text at the focus |
key <combo> |
ctrl+k, Escape, Return, a |
wait <ms> |
|
quit |
stop Chrome and Vite |
The driver picks a free port and prints it as ok ready http://localhost:<port>/.
It kills both children on quit, on EOF, and on a closed stdin.
Run (the assembled app)
window.mjs starts Xvfb, seeds a profile with the network you name, launches
the app twice — once to let it create and migrate its archive, once with the
network in it — and hands you the window. With #344 the app then opens the
channel by itself, so a walk starts with a conversation on screen.
mkdir -p /tmp/shots
printf 'wait 2000
ss /tmp/shots/joined.png
click 657 723
type hello from the harness
key Return
wait 1500
ss /tmp/shots/said.png
quit
' | node scripts/run-ircx/window.mjs --join '#harness'
| command | what it does |
|---|---|
type <text> |
insert text at the focus, a keystroke at a time |
key <combo> |
Return, Escape, ctrl+k, shift+Return |
click <x> <y> |
window coordinates, the window being at the origin |
move <x> <y> |
put the pointer there and press nothing, for anything drawn on hover |
wheel <x> <y> <n> |
scroll there, n notches, up when negative |
ss <file> |
screenshot the display to <file> |
wait <ms> |
|
quit |
stop the app, Vite and Xvfb, and delete the profile |
| option | |
|---|---|
--server <host:port> |
default 127.0.0.1:6667 |
--nick <nick> |
default walker |
--join <#channel> |
seeded as a connect command, repeatable |
--tls |
the seeded network uses TLS, off by default |
--sasl <account:password> |
the seeded network logs in with SASL PLAIN |
--release |
drive the release app instead of a debug build against Vite |
--keep |
leave the profile behind and print where it is |
--profile <dir> |
launch on a profile a --keep run left, as it stands |
--sasl is how a walk becomes two sessions of one account, which is what
MARKREAD is relayed between and what a bouncer-shaped question needs. The
password does not go in the seed: the app reads it from the OS keyring, so the
flag writes it there — the ircx service, keyed by the network's id — and
clears it again on the way out unless --keep was asked for. ergo merges a
second connection into an existing client when it authenticates, so a socket
script with the same credentials is the second session.
--profile is how "does it survive a restart" is asked. A run seeds a fresh
profile, so a draft, a pane layout, a theme and its edits are all gone by the
next one. Keep a profile, do the thing, quit, and launch again on the same
directory; nothing is seeded the second time.
--release is what a figure has to be measured on, and the build for it is
npm run tauri build -- --no-bundle — not cargo build --release. What
decides whether the frontend is inside the binary or fetched from the dev server
is the tauri CLI rather than the cargo profile, and both land on
target/release/ircx. A window driven against the cargo one comes up white with
Could not connect to localhost, and a measurement taken there is a measurement
of an error page. Nothing can tell them apart from outside — the embedded assets
are compressed — so the first screenshot is the check.
A Tailwind hover: rule cannot be walked at all, and says nothing about it.
Tailwind v4 wraps every hover: and group-hover: utility in
@media (hover: hover), and WebKitGTK on Xvfb answers that query hover: none — there is no pointer device on that display, whatever move does with
XTEST. So a control drawn hidden group-hover:block is invisible for the whole
of a walk and reads as a control the app does not have. The same engine on a
real pointer session answers hover: hover.
Reach such a control by its focus twin instead — group-focus-within: is not
inside the media query, so clicking anything focusable in the same group draws
it. move still photographs a bare :hover rule, one written without the
Tailwind variant, which is the only kind this harness can see.
A timeline can only be scrolled with wheel. The scroller is a div
nothing focuses, so no key sent to the window moves it, and click lands on a
message rather than dragging a bar. How far a notch goes is not a constant.
It has been about a third of a message and as much as three whole messages, so a
coordinate read off one screenshot is stale after a single notch. Measure it
in the pane being walked before counting them, and take the screenshot the
coordinate comes from after the last one.
Two frames with nothing between them is how the app is caught moving on its
own. Send an input, photograph, wait, photograph again — any difference is the
app rather than you, and md5sum on the pair is the whole test, since a still
pane is byte-identical. What the difference is takes a real measurement:
shift.py slides a band of one frame over the other and answers in pixels.
Two things it cannot do, and both bite quietly:
- No selectors. Nothing answers for WebKitGTK the way the DevTools Protocol
answers for Chrome, so the window is read by screenshot and clicked by
coordinate. Assertions are your eyes on a PNG, the archive under
--keep, or a second client on the socket. - A coordinate goes stale. The fourth run's first
Retryclick missed because a reconnect drew two rows and pushed the control up the pane between the screenshot and the click. Take the shot, click, and let nothing arrive in between.
Read the wire from a second client, not from ircx. Its timeline draws local copies the moment Enter is pressed and its raw log records the queue rather than the socket, so neither is a clock. A small socket client joined to the same channel reads what the server received.
Measure startup
startup.mjs times exec to each of the four marks in the Startup section of
docs/measurements.md, over profiles it seeds: empty, an archive, networks that
dial. quickserver.mjs is what they dial — registration answered immediately,
so the figure is ircx's rather than a server's.
node scripts/run-ircx/startup.mjs --messages 100000 --networks 3 --runs 3
It drives the real compositor, not Xvfb: the marks are read off
WAYLAND_DEBUG and a frame commit is not something an X server reports, so a
window appears on the operator's screen once per run. --seed-only builds one
of the profiles and stops, for window.mjs --profile <dir> --release to open
somewhere less intrusive.
Seeding an archive by hand is where this goes wrong quietly. Three columns
in messages hold JSON rather than what their names suggest — kind is a
quoted camelCase string, delivery is internally tagged, tags is an array —
and open_targets.kind must be lowercase channel or the conversation reopens
as a query. Rows that get any of it wrong insert perfectly well and count
towards the file size, then fail to deserialise: the archive looks populated,
the timeline comes up empty, and nothing says so. The first version of this
harness measured exactly that. Check a seeded profile with a screenshot before
believing a number taken on it.
networks.sasl_mechanism is a fourth, and it fails wider: the bare word PLAIN
where the column wants "PLAIN" fails one row's deserialisation and takes the
whole network list with it, so the app comes up saying it has no networks
configured while the row sits in the table.
Run (human path)
npm run tauri dev # → the real window, Rust backend and all. Ctrl-C to stop.
The same window on your own display, for looking at rather than driving. It needs a display, and it will refuse to start if another dev server holds port 5183 (see Gotchas).
Test
npm run typecheck && npm run lint && npx vitest run
cargo test --workspace
Do not set CARGO_TARGET_DIR. The default is <checkout>/target, which is
already one directory per worktree — see Gotchas for what sharing one costs.
Gotchas
The frontend throws without Tauri, and the failure looks like CSS.
onFileDropinsrc/lib/ipc.tscallsgetCurrentWindow()unguarded the moment<DropToUpload>mounts. Outside a Tauri webview that throws synchronously and React unmounts the whole tree, leaving a window that is the right dark colour and completely empty — it reads like a stylesheet problem, not a crash.vite.browser.config.mjsinjects awindow.__TAURI_INTERNALS__stub to get past it.TitleBar.tsxguards for this properly with"__TAURI_INTERNALS__" in window; that call site does not.npm run tauri devcannot run twice.vite.config.tspins port 5183 withstrictPort: trueso the Rust side'sdevUrlcannot drift, so a second one — or another agent's worktree — fails outright.driver.mjsasks for port 0 instead and is unaffected;window.mjsneeds 5183 and is not. Ifnpm run devdies with "Port 5183 is already in use", that is another session, not you: leave it alone and usedriver.mjs.It bites twice. A dev server left over from your own earlier run holds the port too, and the app then refuses it by name —
http://localhost:5183/is serving another checkout — which is #233 working.window.mjsreports what the app said rather than only that no window arrived, but the fix is to find the staleviteand kill it.A window opens on your real desktop instead of on
Xvfb. GTK prefers Wayland whenWAYLAND_DISPLAYis set, soDISPLAY=:98alone is not enough: the app appears on the operator's actual screen, theXvfbroot stays black, and nothing anywhere says why.window.mjsunsets it and setsGDK_BACKEND=x11. Driving the app by hand, do the same — andxprop -name ircxagainst the display is what answers "did it map here".pkill -fmatches the shell running it. A pattern likepkill -f target/debug/ircxappears in thebash -cwrapper's own command line, so it kills the shell before the app. Kill by exact name (pkill -x ircx) or by the pid you started.A second ircx on the machine used to make every launch a timeout. The single-instance plugin hands a launch off to the ircx already running and exits 0, so nothing maps on
Xvfband the run dies ontimed out waiting for the app to create its profilewith nothing on stderr underneath it — the app did what it was built to do.window.mjsbuilds with thewalkfeature, which drops the plugin, and builds on every run because a binary an earlier run left may not carry it. Launching the app by hand, build the same way:cargo build --manifest-path src-tauri/Cargo.toml --no-default-features --features walk.Two checkouts must not share one target directory. This entry used to say the opposite — that a fresh worktree rebuilds ~51G, so point
CARGO_TARGET_DIRat another checkout'starget/. Both halves were wrong.Sharing is unsafe. Cargo will run a test binary the other checkout built: on 2026-08-09
cargo test --workspacefailed fourircx-plugintests on aCARGO_MANIFEST_DIRbaked in at compile time, naming a worktree that had since been deleted. That failure at least announced itself. The same mechanism with a binary that still passes tells you a tree is green when nothing in it was tested, andwindow.mjshad a whole extra build step bolted on for the same reason — the app binary belonged to whichever checkout built it last.And the 51G was an accumulated directory, not a build. A fresh worktree's full test build is 84 seconds and 7.1 GB (
docs/measurements.md); the 81 GB in a long-lived checkout is mostlyincremental/grown over months.So build into the default and leave
CARGO_TARGET_DIRunset. If you do need to name one, put it outside the checkout:.gitignoreandeslint.config.jsboth ignoretargetby that exact name, so a directory inside the checkout called anything else gets picked up by lint and by git.Assigning
.valueto an input does nothing React can see. React installs its own setter and listens for the event after it, so a plain assignment updates the DOM while React's state keeps the old value — the field looks edited and nothing else reacts.fillgoes through the prototype's setter and then dispatchesinput. Use it rather thaneval.Form fields have no stable selector.
src/components/onboarding/fields.tsxlabels its inputs with a ReactuseId, so the id is generated per render and nothing in CSS reaches a field by the name a person sees. Everything built on those fields — onboarding, the plugin permissions form — needsfilllabel, which goes through the<label for>.fillonly works where a selector genuinely exists.keycannot type punctuation. It maps a character to a US-layout key code, so(and)are unreachable through it. Usetype, which goes throughInput.insertTextand is layout-independent. This matters for anything pasting a CSS value likergb(...)orurl(...).window.mjs'stypecannot type an emoji, and the failure is sticky. Thattypeis notdriver.mjs's: it goes through XTEST a keystroke at a time, so a character with no keysym stops it dead —err no keycode for keysym 0xf0is what a 👍 gets. Run 36 lost two attempts to it. Worse, the characters typed before the failure are already in the field, so the next attempt appends to them and sends something nobody wrote: a/reactthat died on its emoji left ircx sending a reaction whose value was a msgid. Clear the field (ctrl+a,BackSpace) after anytypethat errors. A reaction's value has no restriction on it, so a word tests the same path.Do not type a path into GTK's folder chooser. Its location bar (
ctrl+l) duplicates injected characters:/tmp/notapluginarrived as/tmp/nootaplugin, and a longer one asclaude-aude-1000forclaude-1000. Shortening the path does not help — a four-character prefix mangled too — and it is not the dropped-keystroke fault #349 fixed, because characters are being added. The save dialogue's Name field takes a typed path perfectly well; this is the folder chooser specifically.Navigate it by clicking instead. Put the folder somewhere the chooser already lists — the app's own working directory is where it opens — then click its row and click Open.
Backend-dependent UI is empty, not broken. Without
--seededeveryinvokerejects, so the network list, plugins, uploads and themes installed on disk show their backend-absent state — the plugins sheet renders "no backend: the frontend is running in a browser" where the list would be. The two built-in themes still load, being compiled in rather than read from disk.--seededanswers instead; anything that must exercise the Rust side itself still needsnpm run tauri dev.scripts/run-ircx/**is excluded from application lint ineslint.config.js. The harness runs in Node and is not application source.
Troubleshooting
ok readynever prints,timed out waiting for the Vite dev server: runnpm ci— a fresh worktree has nonode_modules.err no element matching <sel>: the sheet had not rendered yet. Addwait 400after thekey Returnthat opens it.- Blank screenshot, no error: the Tauri stub did not load. Check the driver
was started with
--config scripts/run-ircx/vite.browser.config.mjs— it is, unless you launched Vite yourself. process exited with 1 before Chrome's debugger: another Chrome is using the profile directory. The driver makes a fresh one under/tmpper run, so this means a stale process;pkill -f ircx-chrome-and retry.