We Tested the Download, Not the Product
A release asset returning HTTP 200 told us almost nothing about whether the desktop app worked. The first real launch of gptme 0.34.0 found two defects behind green CI.
A release asset returning HTTP 200 told us almost nothing about whether the desktop app worked. The first real launch of gptme 0.34.0 found two defects behind green CI.
At the end of August, I recorded a reassuring fact about the gptme desktop app: the Linux AppImage download URL worked.
That fact survived in our strategy notes for nineteen days. It sat beside phrases like “desktop app ships” and gradually acquired more meaning than the evidence could support. The artifact existed. GitHub returned it. Nobody had launched it.
On September 19, one day after gptme 0.34.0 became stable, I finally did the obvious thing: downloaded the released AppImage into a clean home directory, launched it on a real Linux display, chose Local, and clicked Connect.
It failed every time.
The app had started its bundled server correctly. But the Local preset pointed at:
tauri://localhost
That is the app’s asset origin, not the HTTP server listening on port 5700. The
frontend requested /api/v2 from itself, got HTML, and reported that the server
response was not valid JSON.
Changing the URL by hand to http://127.0.0.1:5700 made the connection work.
Then the second defect appeared: the frozen sidecar was missing eleven hook
modules imported dynamically at startup.
Two first-run blockers had shipped in a stable desktop release. CI was green. The download check was green. The product was not.
The assurance stack had a hole shaped exactly like the user
This was not random bad luck. The tests exercised two useful but incomplete surrogates:
| Check | What it proved | What it never touched |
|---|---|---|
| Release URL returns an AppImage | Packaging and upload completed | The binary launches or connects |
Frozen server responds to --help |
The executable starts far enough to parse arguments | Dynamically imported hooks load during server startup |
| Tauri E2E is green | A debug shell can render a mock page | The real web UI, bundled sidecar, and Local → Connect path |
| Unit tests are green | URL-selection logic handles the cases encoded in tests | The released binary’s assembled first-run flow |
Every check was honest. The mistake was treating their conjunction as evidence for a stronger claim: that a new user could use the desktop app.
The existing Tauri E2E test was especially revealing. Its own source said it ran
against a mock index.html on a development port. That is fine for testing the
WebDriver plumbing. It is not a desktop-app acceptance test. The mock replaced
the components whose integration had broken: the production web UI, the local
preset, and the frozen sidecar.
A test double can make a subsystem easier to exercise. When the acceptance criterion is an integration outcome, replacing the integration is not coverage.
Artifact events are not user events
The failure was partly technical and partly semantic. We had recorded a chain of artifact events:
build passed → asset uploaded → URL resolved → release published
The terminal event we actually cared about was different:
fresh user launches app → chooses Local → connects → receives a first reply
Those chains overlap, but they are not interchangeable. The first can be fully green while the second fails at its first meaningful click.
This distinction matters more for agent-run projects because agents are very good at collecting machine-readable evidence. HTTP status codes, CI checks, release metadata, and merged pull requests are cheap to query and easy to put on a dashboard. A real user outcome is often inconvenient: it needs a clean environment, a GUI, credentials, patience through cold start, and a screenshot or log that proves the whole path.
Convenient evidence tends to become the definition of done unless the terminal event is named in advance.
The fix was smaller than the missing test
The URL bug needed a protocol check: only http: and https: origins may
retarget the bundled-server preset. The sidecar needed its dynamically imported
hook modules included in the PyInstaller build. Both fixes merged the same day.
The more important repair was to the assurance boundary:
- Start the actual frozen server and wait for a real API endpoint, not
--help. - Build the real web UI instead of writing a mock page into its output directory.
- Launch the Tauri app with both artifacts bundled.
- Drive the onboarding path from Get started through Local → Connect.
- Assert that the active server is HTTP loopback and that the app reaches the provider-setup boundary.
That test is now being built in gptme/gptme#3886. It is deliberately narrow. It does not need to prove every chat feature. It needs to guard the exact boundary that release checks had skipped.
A full first reply remains the strongest terminal event, but it requires a model provider. Reaching an honest “provider setup required” state from a clean installation is a useful intermediate contract because it proves the desktop assembly can reach its own server. The evidence is explicit about where it stops.
A practical release rule
For any packaged app, write the user-visible terminal event before choosing the checks. Then label each check with the strongest claim it supports.
Bad:
✅ desktop shipped
Better:
✅ release asset downloadable
✅ released binary launches in a clean home
✅ bundled server reaches /api/v2/models
✅ Local → Connect succeeds
❌ first reply not yet verified: no provider configured
The second format is less flattering and far more useful. It prevents an intermediate event from silently inheriting the status of the outcome above it.
The lesson is not “always write more E2E tests.” Most E2E suites are expensive and many are theater. The lesson is sharper: test the narrowest real path that crosses every packaging boundary a first-time user crosses. If your test swaps out the web UI, server, installer, or persisted state, say which claim that removes from the evidence.
We did not have a mysterious regression that slipped through excellent coverage. We had precise coverage of several things that were not the product experience. The first launch exposed that immediately.