Skip to content

test: launch-test real packages in containers + download page - #26

Merged
vietanhdev merged 8 commits into
mainfrom
feat/gui-launch-tests
Jul 19, 2026
Merged

test: launch-test real packages in containers + download page#26
vietanhdev merged 8 commits into
mainfrom
feat/gui-launch-tests

Conversation

@vietanhdev

Copy link
Copy Markdown
Owner

Two things: the packages are now proven to actually run, and the docs have a real download page.

GUI launch tests

Nothing has ever proved the packages run. npm run tauri build exited 0 and "the .deb is 8MB" are both true of a binary that dies before it draws a window — and this app builds a tray icon unconditionally against libayatana-appindicator3, the classic dlopen-panic shape.

The hard part: ThinkUtils reads /proc/acpi/ibm/fan and /sys/class/power_supply/BAT*, none of which exist in a container. So the suite doesn't test hardware paths. It tests that the app starts, renders its full UI, and degrades cleanly — using three hardware-independent signals:

  1. OCR on injected template text. index.html ships only two visible strings plus empty containers. Every other word on screen appears because templateLoader.js fetched a template over tauri:// and injected it. Those labels are literal markup that no /proc read produces — so OCR finding them proves the JS ran, on any machine. Finding index.html's static text while finding no injected label is the exact signature of "WebKit loaded the page, the JS died", checked explicitly because every other assertion passes in that state.
  2. The app declares its hardware state (hw probe: / hw mode:), so "no ThinkPad here" is observed rather than inferred.
  3. The frontend reports uncaught exceptions to the backend. A view dying on an absent sysfs path leaves the sidebar painted and the process alive; that error line is the only tell.

It found two real bugs on first run, both fixed here

hw mode reported full inside a container. Containers inherit the host's /sys, so bat0 and cpufreq were present and only the fan interface was missing. Now keyed on the fan interface — a battery and cpufreq exist on every Linux laptop and prove nothing about ThinkPad support.

The OCR assertion missed the first-run permissions dialog. On a machine never set up — every container — the app correctly opens that dialog over the main view. It comes from templates/dialogs.html, so it's injected-template text and proves the same thing.

Verified locally against real builds

  • deb on ubuntu:24.04PASS
  • rpm on fedora:41PASS, the first time the .rpm has ever been tested by anything

Artifact selection resolves the current version rather than "the only match": three stale builds were sitting in the bundle directory, and release.yml selects with ls *.deb | head -n 1, which sorts 0.1.10 before 0.1.5 and would rename an old package to the new version's name and publish it.

Download page

The Download nav link pointed at the raw GitHub releases list. docs/download.md resolves the latest release through the API at view time — the docs site and release pipeline deploy independently, so a baked-in version goes stale the moment a release ships without a docs rebuild. If the API rate-limits, every button falls back to the releases page.

getting-started.md is restructured as an ordered path, leading with "will this work on my machine" answered by one command. Step 2 is now stated as the step people miss — because presenting fan_control=1 as one prerequisite among several is what produces the "I granted permissions and nothing happened" report.

Also documents the Ubuntu 22.04 polkit 0.105 limitation and the fan safety behaviour.

Incidental fix

.gitignore line 29 read docs/.vitepress/cachebuild/ — a missing newline had merged two entries, so neither the VitePress cache nor build output was ignored.

Nothing proved the packages RUN. 'npm run tauri build exited 0' and
'the .deb is 8MB' are both true of a binary that dies before it draws a
window -- and this app builds a tray icon unconditionally against
libayatana-appindicator3, the classic dlopen-panic shape.

The hard part is that ThinkUtils reads /proc/acpi/ibm/fan and
/sys/class/power_supply/BAT*, none of which exist in a container. So the
suite does not test hardware paths. It tests that the app starts, renders
its full UI, and degrades cleanly when the hardware is absent, using
three hardware-independent signals:

  index.html ships only two visible strings plus empty containers. Every
  other word on screen arrives because templateLoader.js fetched a
  template over tauri:// and injected it. Those labels are literal markup
  that no /proc or /sys read produces, so OCR finding them proves the JS
  ran, on any machine. OCR finding index.html's static text while finding
  NO injected label is the exact signature of 'WebKit loaded the page,
  the JS died' -- checked explicitly, because every other assertion
  passes in that state.

  The app now prints hw probe / hw mode, so 'no ThinkPad here' is an
  observed state rather than an inference.

  The frontend reports uncaught exceptions to the backend. A view dying
  on an absent sysfs path leaves the sidebar painted and the process
  alive; that error line is the only tell.

Running it immediately found two real bugs, both fixed here:

  hw mode reported 'full' inside a container. Containers inherit the
  host's /sys, so bat0 and cpufreq were present and only the fan
  interface was missing. Keyed on the fan interface now -- a battery and
  cpufreq exist on every Linux laptop and prove nothing about ThinkPad
  support.

  The OCR assertion missed the first-run permissions dialog. On a machine
  that has never been set up -- every container -- the app correctly
  opens that dialog over the main view, so the sidebar is not what is on
  screen. It comes from templates/dialogs.html, so it is injected
  template text and proves the same thing.

Verified locally against real builds: deb on ubuntu:24.04 PASSES, and rpm
on fedora:41 PASSES -- the first time the .rpm has ever been tested by
anything.

Artifact selection resolves the CURRENT version rather than taking the
only match or the first: three stale builds were sitting in the bundle
directory, and release.yml selects with 'ls *.deb | head -n 1', which
sorts 0.1.10 before 0.1.5 and would rename an old package to the new
version's name and publish it.

Also fixes .gitignore, where a missing newline had merged two entries
into 'docs/.vitepress/cachebuild/' -- so neither the VitePress cache nor
build output was ignored.
The Download nav link pointed straight at the GitHub releases list, which
shows every asset for every version and leaves the reader to work out
which file they want.

docs/download.md resolves the latest release through the GitHub API at
view time rather than baking a version in at build time -- the docs site
and the release pipeline deploy independently, so a hard-coded version
would go stale the moment a release ships without a docs rebuild. If the
API is unreachable or rate-limits (60/hour unauthenticated), every button
falls back to the releases page, which always works.

Assets are matched by predicate rather than exact filename so a version
bump needs no edit here, and each predicate pins the architecture suffix.
That is load-bearing rather than tidiness: find() returns the FIRST
match, so a loose predicate would silently hand out the wrong package the
day a second architecture is added, and the failure is quiet and
user-side -- the page looks right, the download works, the package
refuses to install.

getting-started.md is restructured as an ordered path rather than a list
of prerequisites, and leads with the question a reader actually has --
'will this work on my machine' -- answered by one command.

Step 2 is now stated as the step people miss, because it is: the
thinkpad_acpi module refuses every fan write unless loaded with
fan_control=1, and that is fixed at module load, so granting permissions
cannot fix it. Presenting it as one prerequisite among several is what
produces the 'I granted permissions and nothing happened' report.

Also documents the Ubuntu 22.04 polkit 0.105 limitation, which silently
makes passwordless fan control not work there, and the fan safety
behaviour -- revert on disable, on unreadable sensors, on exit, plus the
firmware watchdog.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 19, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
❌ Deployment failed
View logs
thinkutils 110ec0e Jul 19 2026, 03:38 PM

…anifests

Distro packaging was blocked by one thing: the fan helper installed to
/usr/local/bin at runtime. Debian Policy 9.1.2 and the Fedora guidelines
both forbid a package writing there, and a helper materialised by a
button click is not package-owned -- dpkg -L would not list the most
security-sensitive file the app uses, and uninstalling would leave a
root-owned binary and a polkit rule behind.

HELPER_PATH becomes HELPER_CANDIDATES, searched in order:

  /usr/lib/thinkutils/...      Debian and Arch convention
  /usr/libexec/thinkutils/...  Fedora convention
  /usr/local/bin/...           legacy self-install, kept so existing
                               installs keep working

setup_permissions() now skips installing the helper and rule entirely
when helper_is_packaged(), because overwriting those files puts the
package database out of sync with the filesystem.

The path had been duplicated in four places -- fan_control, mcp,
fan_curve, and inside the polkit rule text. The rule is now generated
from the constant, since a rule naming a path the helper is not at
grants nothing while looking correct.

Two security improvements fall out of generating it:

  subject.local && subject.active is now required. Without it any SSH
  session belonging to a wheel/sudo user inherited passwordless
  hardware control, as did a background session the user had switched
  away from.

  Packages ship the rule to /usr/share/polkit-1/rules.d, not /etc.
  /etc is the administrator's namespace; a package writing there
  shadows their rules and is never cleaned up.

packaging/ adds the AUR PKGBUILD and COPR spec, plus the helper and rule
as generated artifacts (cargo run --example gen-packaging).

tests/packaging.rs is the point of all this: 7 tests asserting the
committed files match what the source generates, that each format
installs where the app actually searches, that nothing writes to
/usr/local, that the rule goes under /usr/share, and that versions
agree. The drift they prevent fails SILENTLY -- polkit denies, the app
falls back to a password prompt, and it reads as a permissions problem
rather than a packaging bug.

bump-version.sh now covers PKGBUILD and the spec too, and ci.yml calls
it rather than keeping its own copy of the file list, so adding a
packaging file cannot leave CI checking a stale subset.

Suite: 80 -> 87.
The gui-launch job built on the ubuntu-24.04 runner and then tested the
result on Ubuntu 22.04, where it failed to start:

  thinkutils: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.39'
  not found (required by thinkutils)

Tauri links against the build host's glibc, so a 24.04 build declares
2.39 and will not run on 22.04 (2.35) or Debian 12 (2.36). release.yml
already builds on 22.04, so real releases were unaffected -- CI was
testing a binary no user would ever receive, which is worse than not
testing at all.

The job now builds inside an ubuntu:22.04 container, matching release.yml
exactly, so what the suite launches is what ships. That is the same
reasoning already recorded in release.yml for why its runner stays on
22.04 despite deprecation; I had documented it there and then not applied
it to the job I wrote afterwards.

Also sets APPIMAGE_EXTRACT_AND_RUN, because linuxdeploy is itself an
AppImage and mounts via FUSE, which a container does not have -- that
fails AFTER the .deb and .rpm have already built, so it reads like a
Tauri bug rather than a missing environment variable.

Worth noting the suite behaved exactly as designed: ubuntu:24.04 passed
every assertion in the same run, so the failure isolated to the one
target whose glibc floor was violated.
…hout it

The container had no jq, so VERSION resolved empty and every artifact
glob became thinkutils__amd64.deb -- matching nothing. The run reported
'no artifact matching' while the actual cause, one line earlier, was
'jq: command not found'.

jq is installed now, and an unreadable version aborts with a clear
message instead of building a pattern that silently matches nothing.
Completes the packaging set: deb/rpm/AppImage from Tauri, AUR, COPR, and
now a PPA source build.

The constraint that shapes all of it is that Launchpad builders have no
network, so every Rust dependency is vendored into the .orig tarball and
the build runs CARGO_NET_OFFLINE=true. Verified end to end rather than
assumed: cargo vendor produces 543 crates, and cargo check --offline
against that tree succeeds.

Two findings worth recording, both tested rather than reasoned about:

  npm is NOT a build dependency. tauri.conf.json has no
  beforeBuildCommand and frontendDist is a directory of static files, so
  there is no bundler and no node_modules to vendor. Vendoring a JS
  dependency tree deterministically is the hardest part of packaging a
  typical Tauri app, and this one avoids it entirely. debian/control has
  a test asserting npm never appears there.

  The vendor tree is 855 MB, 77 MB compressed, and roughly three quarters
  of it is Windows-only crates in a Linux-only package. Deleting them
  looks like an easy 73% saving and does not work: cargo requires a
  vendored source for every crate in Cargo.lock, including platform-gated
  ones it never compiles. Removing them makes cargo check --offline fail
  resolving chrono. Recorded in the script so it is not retried.

debian/rules deliberately avoids the Tauri bundler, which downloads
linuxdeploy at build time, and installs every file by hand -- also the
only way to place the helper somewhere FHS-legal.

jammy is excluded, and not for toolchain reasons: it ships polkit 0.105,
whose JS rules engine Debian and Ubuntu patched out, so the passwordless
rule would be read by nothing and every fan change would prompt.

Five new tests cover the offline flags, the dh_clean -X.orig override
(without which vendored Cargo.toml.orig files are deleted and the build
fails a checksum on Launchpad but not locally), that the bundler is not
invoked, and that control stays templated.

Two of those tests failed on first run by matching their own explanatory
comments, so assertions now read only real directives -- the same trap
the existing /usr/local check had already sidestepped.
The previous fix put the whole gui-launch job inside an ubuntu:22.04
container so the packages would be built on the glibc floor. That part
was right and necessary -- a 24.04 build requires GLIBC_2.39 and refuses
to start on 22.04 or Debian 12.

But a job running inside a container cannot spawn the sibling containers
the launch test needs, so it failed with 'docker: command not found'.

Split into two:

  bundle     builds inside ubuntu:22.04, uploads the packages
  gui-launch runs on the plain runner, downloads them, drives Docker

bundle also now asserts the floor directly rather than waiting for a
launch failure to reveal it: objdump the highest GLIBC_ symbol required
and fail if it is above 2.35. Verified against a locally built binary,
which correctly reports GLIBC_2.39 and would fail the check -- that
binary is exactly what CI was shipping to the 22.04 test before.

Reporting the floor as a named error beats discovering it as a broken
download three targets into a launch matrix.
The AppImage target failed with:

  error while loading shared libraries: libgbm.so.1

An AppImage bundles WebKit and GTK but NOT Mesa -- it expects the host to
provide the graphics stack. A bare container has none, so the test was
judging an environment no user actually has. Any real desktop has libgbm.

Installs libgbm1, libgl1, libegl1, libglx-mesa0 and mesa-vulkan-drivers
in the AppImage container only.

Cannot be verified locally: this host is Ubuntu 26.04, so a locally built
AppImage bundles glib and WebKit requiring up to GLIBC_2.43 and cannot
start on 22.04 regardless. Which is itself worth knowing -- the AppImage
inherits the build host's glibc floor MORE aggressively than the binary
does (2.43 vs 2.39), because it ships those libraries too. CI builds it
inside the 22.04 container now, so that floor is pinned there.

The other four targets already pass -- deb on 22.04, 24.04 and Debian 12,
and rpm on Fedora 41 -- each recognising seven injected template labels.
@vietanhdev
vietanhdev merged commit 8ef0920 into main Jul 19, 2026
9 of 10 checks passed
@vietanhdev
vietanhdev deleted the feat/gui-launch-tests branch July 19, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant