With the test backend, the app is built against the test directory's address, and the smoke test runs a new search case: after the backend case's checks, in both layouts, "Search in Directory" and the full search with a SimpleX name, and with the toolbar at the bottom the full search in the New chat sheet.
Desktop app CI on Linux
.github/workflows/desktop-linux.yml builds the desktop app from the branch on a GitHub runner (Ubuntu 24.04), runs its JVM tests (:common:desktopTest), and drives the app on a virtual display: onboarding to the chat list, then the chat list with the toolbar at the bottom (the default) and at the top. On request it also runs Claude Code driving the same path, a test backend, and the latest release as a baseline.
Running it
On any branch, in either of two ways:
- Dispatch: Actions → desktop-linux → Run workflow, with the
claudeandbackendcheckboxes (both off by default). - Push with a tag in the head commit's message, which is how agents in push sessions ask for a run:
| Tag | Runs |
|---|---|
[app-ci] |
the scripted test, here and in the iOS app's workflow where the branch has it |
[app-ci:desktop] |
the scripted test, here only |
[claude-test] |
the scripted test and the Claude job, here and in the iOS app's workflow where the branch has it |
[claude-test:desktop] |
the scripted test and the Claude job, here only |
[ci-backend] |
with one of the above: also the test backend and the release job |
A push without these tags (or with only iOS tags) starts a run whose jobs are all skipped, and it doesn't cancel a run in progress. A newer run that was asked for cancels an older one on the same branch. Pushes to master and stable never start it, because a squash merge's message lists the merged commits' subjects with their tags; dispatch it there instead.
Every run builds the core library, and the backend when it's asked for, from the branch's own Haskell code. It reuses an earlier build only when the sources are unchanged: the core by a cache keyed on src/**, cabal.project, simplex-chat.cabal, PRIVACY.md and the build scripts; the backend by an artifact of the same branch named after a hash of its sources (30 days). So a branch that changes core and app code together tests both.
Jobs
| Job | Does | Time |
|---|---|---|
plan |
chooses the jobs, finds an earlier backend build | 10 s |
core |
scripts/desktop/build-lib-linux.sh via build-core.sh |
15 s cached, about 23 min not |
app |
the app (:desktop:createDistributable), desktopTest, smoke-test.sh |
about 10 min: build 4.5, desktopTest 0.5, smoke test 3.5 |
claude (opt-in) |
the app at its first screen, then Claude Code drives it | about 7 min, Claude 2.5 |
backend (opt-in) |
build-backend.sh: simplex-chat, simplex-directory-service, and smp-server from the simplexmq commit cabal.project pins |
about 27 min, skipped when reused |
release (opt-in) |
the latest release's .deb for this Ubuntu, onboarding only, to tell a branch's failure from the runner's | 1.5 min |
Queueing for a runner adds from under a minute to 20 minutes. Results are artifacts: desktop-smoke-test (numbered screenshots, the text OCR read from each, steps.log, checks.txt, app.log), desktop-test-report, desktop-claude-test, and the backend's logs. The job summaries list the checks, the desktopTest results and the Claude usage.
Scripts
ui: the UI driver. Compose for Desktop has no accessibility tree on Linux, so it finds text on screenshots by OCR (tesseract) and acts with xdotool on an Xvfb display.uialone prints its commands: start (with app settings), shot, text, find, wait, click, click-text, scroll, type, key, avatar (a profile image's colour), ink (a text's height), do (several in a row), stop.screen-text.py,screen-colour.pyandscreen-ink.pyare its helpers.smoke-test.sh <app dir> <out dir> [start|onboarding|setup|backend]: the scripted cases; the default is onboarding and both layouts.setupalso creates a local group and makes the test SMP server the app's only names server;backenddoes that, connects to the seeded name, and checks both layouts.backend.sh start <tar.gz> <dir>/stop <dir>: the test backend, below.claude-test.sh,claude-test.md(the prompt),claude-guard.py,claude-usage.py: the Claude job.build-core.sh,install-core.sh,build-backend.sh,earlier-run.sh,release-app.sh,test-summary.py: builds, reuse and reports.
The test backend
backend.sh starts, on the runner, with CLI peers:
- a chat relay (
--relay --headless) and a directory service on the preset servers; - a group
ci_groupand a channelci_channelwith letter avatars as large as the apps make them (avatar), registered and approved by the adminci_admin, and found by a new user who sends the directory "ci seed"; - an SMP server on 127.0.0.1:5223 whose
[NAMES]section asksnames-resolver.py, and a contactci_contactwhose address it resolves asseeded.testing, checked with a connect plan from another client.
It writes the directory's address to <dir>/directory-link and the SMP server's to <dir>/names-server. The app reaches the names server only with private routing set to unknown servers (ui start … NetworkSMPProxyMode=Unknown), since the preset proxies can't reach 127.0.0.1, and with the test server's receiving role off: a directory can't answer requests whose reply queue is on it.
The Claude job
It needs the GitHub environment app-sim-claude with the secret CLAUDE_CODE_OAUTH_TOKEN, a Claude subscription token. Which branches may use it is decided only by that environment's branch rule in the repository settings: on a branch the rule doesn't allow, a requested Claude run fails at the environment check. The token goes only to the step that runs claude, and every ui call unsets it. With [ci-backend] the job also starts the test backend for prompts that use it, and waits for its build; this smoke path's prompt doesn't use it. The job installs Claude Code 2.1.296 and runs claude-haiku-5-5 with --max-turns 60 and --max-budget-usd 3. Its Bash runs only single ui commands (claude-guard.py, a PreToolUse hook: Claude Code runs read-only commands without asking in every permission mode). Before anything the session wrote is printed or uploaded, claude-test.sh checks it for the token; if it's there, the output, transcript, Claude Code's stderr and the guard log are deleted and the job fails. It passes when Claude's final message ends with CLAUDE-TEST: PASS; report.md, the transcript, the guard's log and claude-usage.json are in desktop-claude-test.
Cost
The runners are free for this public repository. A Claude run spends the subscription's usage limits, which interactive sessions share: Haiku 5.5 does this smoke path in 15 to 17 turns, about 0.008 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription).