From a48321ee27e4765ff873b7fa02ba8e940d5dc474 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 30 Jul 2026 10:29:33 +0200 Subject: [PATCH] test(client): CI runs the binaries it ships MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 0.22.0 clobber proved a gap no build gate covers: a wrong program wearing the right binary name compiles green. `cargo build -p punktfunk-client-session` happily shipped the GTK shell's three-line Windows stub as punktfunk-session, because nothing between commit and release ever EXECUTED the result. Two integration tests close the class, and they run under gates that already exist (ci.yml's workspace test on Linux, windows.yml's test step on Windows): - contract_smoke spawns the real punktfunk-session against a refusing port and asserts the stdout contract answers — whatever fails first on the machine (presenter init headless, the dial elsewhere), the binary must SAY so in a contract line. Proven non-vacuous by planting the 0.22.0 stub and watching it fail, then pass again on the real main.rs. - cli_smoke runs the real punktfunk over its help surface (stdout, exit 0) and an unknown verb (stderr, exit 5) — store-free and network-free, safe on any runner. windows.yml now gates punktfunk-cli in all four steps (build, clippy, fmt, test): the MSIX has shipped its `punktfunk.exe` alias since bf981027, but only the release workflow ever compiled it — a PR could break the CLI and find out on tag day. The session README also stops selling `--pair` as the enrolment route (deprecated by bf981027, `punktfunk pair` is the door) and says out loud what this binary is: a deliberately dumb renderer the GTK shell, the WinUI shell and the CLI all call into through one brain. Co-Authored-By: Claude Fable 5 --- .gitea/workflows/windows.yml | 12 +++-- clients/session/README.md | 17 +++--- clients/session/tests/contract_smoke.rs | 69 +++++++++++++++++++++++++ 3 files changed, 87 insertions(+), 11 deletions(-) create mode 100644 clients/session/tests/contract_smoke.rs diff --git a/.gitea/workflows/windows.yml b/.gitea/workflows/windows.yml index 3f75d354..c3e6c877 100644 --- a/.gitea/workflows/windows.yml +++ b/.gitea/workflows/windows.yml @@ -139,16 +139,20 @@ jobs: # Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session # drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op # for the shell, which has no features). + # punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so + # a CLI that only the release workflow compiles is a release-day surprise. Its tests + # RUN the binary (help contract), as the session's contract_smoke runs the session — + # the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber). - name: Build shell: pwsh run: | $sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } - cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }} + cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }} - name: Clippy (-D warnings) shell: pwsh run: | - $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk') + $pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk') $sf = @() if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') } cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings @@ -156,9 +160,9 @@ jobs: - name: Rustfmt check if: matrix.target == 'x86_64-pc-windows-msvc' shell: pwsh - run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check + run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check - name: Test if: matrix.target == 'x86_64-pc-windows-msvc' shell: pwsh - run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }} + run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }} diff --git a/clients/session/README.md b/clients/session/README.md index 4bc44e12..bf1517c4 100644 --- a/clients/session/README.md +++ b/clients/session/README.md @@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st presenter of the Linux client re-architecture (punktfunk-planning: `linux-client-rearchitecture.md`). +This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell +(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the +same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings, +wake) and hands the result down, normally as a `--resolved-spec` file. It reads the +shared stores only as the compat fallback for a bare hand-launched invocation. + ``` punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats] punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen] -punktfunk-session --pair --connect host[:port] [--name LABEL] ``` `--browse` opens the console game library (the Skia coverflow over the animated aurora) @@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client (`punktfunk-client`), so enrolling on either side makes the other work; this binary never connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store). -`--pair --connect host[:port]` runs the SPAKE2 ceremony with no window and no -toolkit, prints `paired : fp=`, and exits — the route for a machine that -has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the -label the host files this client under, defaulting to the hostname. It is in the -`--no-default-features` build too: enrolling must never be the reason a minimal image has -to pull in Skia. +Pairing is `punktfunk pair ` — the CLI, which ships alongside this binary in every +package and needs no window and no toolkit either. `punktfunk-session --pair` still works +for one release (someone's provisioning script calls it today) but prints a deprecation +notice: pairing is a trust ceremony and belongs to the brain, not a renderer. Stdout is the machine interface: `{"ready":true}` after the first presented frame, `stats: …` once per second while the overlay tier isn't Off (always the full detailed diff --git a/clients/session/tests/contract_smoke.rs b/clients/session/tests/contract_smoke.rs new file mode 100644 index 00000000..52379b3f --- /dev/null +++ b/clients/session/tests/contract_smoke.rs @@ -0,0 +1,69 @@ +//! Runs the REAL `punktfunk-session` binary and asserts it speaks the stdout contract. +//! +//! 0.22.0 shipped a stub as `punktfunk-session` — a stray copy of the GTK shell replaced +//! this crate's `main.rs`, and every build gate stayed green because the wrong program +//! compiled perfectly. Nothing in CI ever *ran* the binary; the failure only existed at +//! runtime, as a connect that silently bounced back to the host list. This test is the +//! gate that would have caught it: whatever the binary does, it must SAY so on stdout. +#![cfg(any(target_os = "linux", windows))] + +use std::io::Read as _; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// A failing connect must still produce a contract line. Port 1 on loopback refuses +/// instantly and the all-zero pin keeps trust logic out of the way; whatever fails first +/// on this machine — presenter init on a headless runner, the dial on a box with a +/// display — the contract requires one `{"error"…}` JSON line on stdout saying so. (On a +/// desktop this may flash a window for under a second; the connect refuses immediately.) +#[test] +fn a_failing_connect_still_speaks_the_contract() { + let mut child = Command::new(env!("CARGO_BIN_EXE_punktfunk-session")) + .args([ + "--connect", + "127.0.0.1:1", + "--fp", + &"0".repeat(64), + "--connect-timeout", + "5", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn punktfunk-session"); + + let mut stdout = child.stdout.take().expect("piped stdout"); + let reader = std::thread::spawn(move || { + let mut buf = String::new(); + let _ = stdout.read_to_string(&mut buf); + buf + }); + + // Generous bound: presenter init on a cold CI runner can be slow, but a healthy + // binary answers in seconds. Only a hang (or a stub that inherited our stdout and + // blocked) gets anywhere near it. + let deadline = Instant::now() + Duration::from_secs(120); + loop { + match child.try_wait().expect("wait on punktfunk-session") { + Some(_) => break, + None if Instant::now() > deadline => { + let _ = child.kill(); + let _ = child.wait(); + panic!("punktfunk-session neither exited nor spoke within 120 s"); + } + None => std::thread::sleep(Duration::from_millis(200)), + } + } + + let out = reader.join().expect("stdout reader"); + let spoke = out.lines().map(str::trim).any(|l| { + l.starts_with('{') + && (l.contains("\"error\"") || l.contains("\"ready\"") || l.contains("\"ended\"")) + }); + assert!( + spoke, + "no stdout-contract line — the wrong program may be wearing this binary's name. \ + stdout was:\n{out}" + ); +}