10 KiB
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. With the test backend, both the scripted test and Claude also search the test directory in both layouts, including with a SimpleX name typed, which only "Connect to …" looks up.
Running it
The workflow runs only when started by hand, on any branch, from the Actions tab or with gh workflow run: App tests on GitHub's runners says how and lists the inputs.
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, as an artifact of the same branch named after a hash of its sources (30 days): for the core src/**, cabal.project, simplex-chat.cabal, PRIVACY.md and the build scripts, for the backend src/**, the CLI's and the directory's sources, cabal.project, simplex-chat.cabal and its build script. So a branch that changes core and app code together tests both. The workflow saves no Actions caches, which this repository evicts within hours and shares with build.yml.
Jobs
| Job | Does | Time |
|---|---|---|
plan |
chooses the jobs, finds earlier core and backend builds | 10 s |
core |
scripts/desktop/build-lib-linux.sh via build-core.sh |
skipped when reused, about 24 min not |
app |
the app (:desktop:createDistributable), desktopTest, smoke-test.sh |
about 12 min: build 4.5, desktopTest 0.5, smoke test 3.5 (about 25 with the backend's searches) |
claude (opt-in) |
after app passed: its distributable at the first screen, or with the backend set up at the chat list, then Claude Code drives it |
about 4 min, Claude 3 (about 15 with the backend's search test) |
backend (opt-in) |
build-backend.sh: simplex-chat, simplex-directory-service, and smp-server from the simplexmq commit cabal.project pins |
about 30 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; desktop-app, the distributable for the Claude job, is kept a day. 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, right-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|search]: 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;search, which the workflow runs when the backend started, also searches in both layouts: "Search in Directory" for "ci seed", which with the toolbar at the bottom first asks "Enable Directory search?" (Cancel searches nothing and keeps "Search in Directory", then Enable searches; no later search asks), and the search for the nameseededwith Enter, which shows the local chat and the directory results with no "Names" header and noci_contactrow, keeps "Connect to seeded.testing", and orders "Connect to …", the local chat, then the results from the field; and with the toolbar at the bottom the same search in the New chat sheet. It checks one list without a "Directory" header, "Open" in each result's row, avatars, counts, the load row ("Load more" and "1 left" after the first page, read apart because OCR can misread the middle dot), no "Search in Directory" while results show, the cards hidden while results show, and, with the toolbar at the bottom, a right click on#ci_groupopening a menu with "Open". It then opensci_channelwith its row's Open (the prepared channel with "Tap Join channel", no "Open channel" alert on the way, the subscriber count in the toolbar and the banner), joins it, and checks, in both layouts, the note "All results on this page are already in your chats" when the typedci_channelfinds only the listed chat, and, with "seeded notes" open andci seedtyped,ci_channelas its chat's row among the results (OCR reads no#before its name and no "Open" next to it;#ci_groupkeeps "Open") that opens the channel without an alert; and the same row in the New chat sheet. A passing search run logs 143 checks, the backend's two included, 117 of them before Open onci_channel.backend.sh start <tar.gz> <dir>/resume <tar.gz> <dir>/stop <dir>: the test backend, below;resumestarts the same backend again from a stopped work dir, for the Claude job.claude-test.sh,claude-test.md(the prompt),claude-search.md(the prompt when the backend runs),claude-scope(whether the prompt uses the test backend),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", and by a search request as the app sends it (/_service_request), which must page through them, as their images don't fit in one response; - 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 workflow points the app at the test directory before building it (DIRECTORY_SERVICE_LINK in DirectorySearch.kt, the only change it makes to app sources).
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. It runs once the app job has passed, on that job's distributable, one per branch at a time. The token goes only to the step that runs claude; claude-test.sh passes it to the claude process alone, and every ui call unsets it too. The job starts the test backend when claude-scope says backend: yes, as it does on this branch; a run that asks for Claude must then ask for the backend too, or the plan job fails. The app job built the distributable pointing at its own backend's directory, which a new backend would not have, so the app job saves that backend's stopped work dir (the artifact desktop-backend-state, without the binaries) and the Claude job starts it again with backend.sh resume: the same servers' keys and databases, so the same addresses. That artifact holds the test backend's databases and server keys, unencrypted, and like every artifact of this public repository anyone signed in to GitHub can download it while it is kept (a day); with it, someone could act as the test directory or relay while the Claude job runs and show the session crafted results. This is an accepted risk: these are throwaway test identities, the session has no shell and no token in reach of its tools, so what is at stake is the test's integrity, and encrypting the state would need a key only the two jobs hold, a second secret, which this repository's rules for the Claude job don't allow. With the backend it sets the app up at the chat list (smoke-test.sh … setup) and gives Claude claude-search.md, the online search test, in place of the onboarding path in claude-test.md: the directory search with its consent, Open, the load row and the right-click menu (part A) and the Search key, which searches the directory only and keeps "Connect to …" (part B), in both layouts, then part C, which opens ci_channel with its row's Open, without an alert, joins it, and checks that later searches show it as a chat (the note for a page of chats already listed, its chat row among the results of the chat list and of New chat, opening without an alert). The job installs Claude Code at the version the iOS workflow pins too, and runs claude-haiku-5-5 with --max-turns 120, --max-budget-usd 3 and the iOS sessions' other flags (--restricted, --permission-prompts none, --effort high). 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 30 turns, 0.01 to 0.02 USD estimated at API prices (Claude Code's estimate, not a bill on a subscription).