Files
hermes-agent/tests/install/installer-script-e2e.sh
ethernet aef539b47a fix(install-e2e): persist input zoom across window lifecycle events
Share zoom preparation across both launchers and stage the helper with each driver. Use the Appearance preference bridge and verify page zoom rather than display DPR.

Real Electron regressions fail with transient zoom on focus/navigation and pass with persistence. Repeated click-throughs, onboarding unit tests, E2E typecheck and lint passed. The historical onboarding timeout and full install/update matrix remain unverified.
2026-09-05 22:55:10 -04:00

489 lines
22 KiB
Bash
Executable File

#!/usr/bin/env bash
# Prove a user who installed OLD via the installer script can reach HEAD.
#
# The POSIX sibling of tests/install/windows-e2e.ps1, sharing its
# staging trick and replacing the old bubblewrap sandbox: instead of a fake
# Internet (MITM proxy + upload-pack shim), every git process is pointed at a
# local bare clone with url.<file://serve.git>.insteadOf rewrites for both
# canonical repo URLs in a driver-owned GIT_CONFIG_GLOBAL. The installer and
# updater run byte-for-byte against their real URLs and land on serve.git;
# `main` serves OLD during the install, then advances to HEAD for the update
# leg -- an update becomes available exactly the way it does for a real user.
# No bwrap, no slirp4netns, no TLS interception; the CI runner is disposable,
# so the host IS the sandbox.
#
# install.sh itself is not curl'd: the install leg runs the copy shipped AT
# the OLD ref (what a user who installed then actually executed), and the
# installer-script update leg runs HEAD's copy (what the website serves at
# update time).
#
# Phases (mirroring the windows driver):
# stage bare-clone this checkout to serve.git, park main at OLD
# install run OLD's scripts/install.sh under the redirect; assert the
# install landed on OLD with a working `hermes`
# update advance served main to HEAD, apply ONE update method, assert
# the checkout landed on HEAD with a working `hermes`
#
# Usage:
# tests/install/installer-script-e2e.sh --update-method hermes-update|installer-script|installer-script+desktop
# [--install-method installer-script|installer-script+desktop]
# [--install-ref REF]
#
# --install-method installer-script the plain one-liner (default)
# installer-script+desktop the one-liner with its desktop
# stage opted in (--include-desktop)
# --update-method hermes-update `hermes update`
# installer-script re-run install.sh (HEAD's copy)
# installer-script+desktop re-run with --include-desktop
# hermes-desktop-app-update launch the app via `hermes
# desktop` (spawn captured, Playwright
# drives it) and click Update now
# --install-ref what to install first; anything git resolves. Default:
# the newest release tag in the checkout.
#
# Requires a clean full-history checkout with release tags fetched.
set -euo pipefail
# One time base for every transcript in this leg: ts_prefix stamps lines
# relative to TS_BASE, so all logs share the driver's clock and a single
# playback.html offset slider aligns every file with the recording.
export TS_BASE=$SECONDS
INSTALL_METHOD="installer-script"
UPDATE_METHOD=""
INSTALL_REF=""
while [ "$#" -gt 0 ]; do
case "$1" in
--install-method)
[ "$#" -ge 2 ] || { echo 'error: --install-method needs a value' >&2; exit 1; }
INSTALL_METHOD="$2"; shift 2 ;;
--update-method)
[ "$#" -ge 2 ] || { echo 'error: --update-method needs a value' >&2; exit 1; }
UPDATE_METHOD="$2"; shift 2 ;;
--install-ref)
[ "$#" -ge 2 ] || { echo 'error: --install-ref needs a value' >&2; exit 1; }
INSTALL_REF="$2"; shift 2 ;;
-h|--help) sed -n '2,45p' "$0"; exit 0 ;;
*) echo "error: unknown argument: $1" >&2; exit 1 ;;
esac
done
case "$INSTALL_METHOD" in
installer-script|installer-script+desktop) ;;
*) echo "error: --install-method must be installer-script or installer-script+desktop, got '$INSTALL_METHOD'" >&2; exit 1 ;;
esac
case "$UPDATE_METHOD" in
hermes-update|installer-script|installer-script+desktop|hermes-desktop-app-update) ;;
*) echo "error: --update-method must be hermes-update, installer-script, installer-script+desktop or hermes-desktop-app-update, got '$UPDATE_METHOD'" >&2; exit 1 ;;
esac
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
REPO_URL_SSH="git@github.com:NousResearch/hermes-agent.git"
REPO_URL_HTTPS="https://github.com/NousResearch/hermes-agent.git"
# Everything lives OUTSIDE the checkout; an untracked dir inside the repo
# would make later dirty-tree checks lie.
WORK_ROOT="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/hermes-installer-script-e2e"
LOG_DIR="${HERMES_E2E_LOG_DIR:-$WORK_ROOT/logs}"
SERVE_REPO="$WORK_ROOT/serve.git"
step() { printf '\n=== %s ===\n' "$*"; }
ok() { printf ' OK %s\n' "$*"; }
fail() { printf 'E2E ASSERTION FAILED: %s\n' "$*" >&2; exit 1; }
# shellcheck source=../e2e-assets/ts-prefix.sh
source "$(dirname "$0")/e2e-assets/ts-prefix.sh" 2>/dev/null || ts_prefix() { cat; }
# Full transcript in the job log, collapsed (GitHub renders ::group:: as a
# fold; plain text anywhere else). Win or lose -- a green install's log is
# how you diagnose the leg that fails next.
log_group() {
printf '::group::%s\n' "$1"
cat "$2"
printf '::endgroup::\n'
}
rm -rf "$WORK_ROOT"
mkdir -p "$WORK_ROOT" "$LOG_DIR"
# --- stage: serve.git with main parked at OLD --------------------------------
step "staging serve.git (main -> OLD)"
# Tracked changes only (-uno): the bare clone serves committed objects, so a
# modified tracked file means HEAD is not the code being reviewed -- but an
# untracked file (scratch notes, this driver before it lands) cannot leak
# into the clone at all.
[ -z "$(git -C "$REPO_ROOT" status --porcelain -uno)" ] \
|| fail "checkout has uncommitted tracked changes; the staged clone must be a reviewable commit"
if [ -z "$INSTALL_REF" ]; then
INSTALL_REF="$(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-creatordate | head -1)"
[ -n "$INSTALL_REF" ] || fail "no release tags in the checkout to use as OLD"
fi
OLD_SHA="$(git -C "$REPO_ROOT" rev-parse "${INSTALL_REF}^{commit}")"
HEAD_SHA="$(git -C "$REPO_ROOT" rev-parse HEAD)"
[ "$OLD_SHA" != "$HEAD_SHA" ] || fail "OLD ($INSTALL_REF) IS HEAD; no update would be available"
git clone --bare --quiet "$REPO_ROOT" "$SERVE_REPO"
git -C "$SERVE_REPO" update-ref refs/heads/main "$OLD_SHA"
git -C "$SERVE_REPO" symbolic-ref HEAD refs/heads/main
# The installer may pin a commit that is reachable but not at a ref tip.
git -C "$SERVE_REPO" config uploadpack.allowAnySHA1InWant true
ok "serve.git main = $OLD_SHA ($INSTALL_REF), update target $HEAD_SHA"
arm_redirect() {
# --- the git URL redirect -----------------------------------------------------
# we redirect to our own repo so we can play around with what commit hermes thinks we're on.
# A driver-owned global gitconfig, NOT GIT_CONFIG_COUNT/KEY_n/VALUE_n env
# config: install.sh sets those itself and would clobber ours.
actual_git_url="$(git -C "$REPO_ROOT" remote get-url origin)"
GIT_CFG="$WORK_ROOT/gitconfig"
cat > "$GIT_CFG" <<EOF
[url "file://$SERVE_REPO"]
insteadOf = $actual_git_url
insteadOf = $REPO_URL_HTTPS
insteadOf = $REPO_URL_SSH
EOF
export GIT_CONFIG_GLOBAL="$GIT_CFG"
# check it worked
expected_git_url="file://$SERVE_REPO"
actual_git_url="$(git -C "$REPO_ROOT" remote get-url origin)"
if [[ "$actual_git_url" != "$expected_git_url" ]]; then
fail "failed git remote get-url shim: origin resolves to '$actual_git_url', expected '$expected_git_url'"
fi
ok "git URL redirect via GIT_CONFIG_GLOBAL=$GIT_CFG"
# shim git and make 'git remote get-url origin' report the actual HA upstream
# insteadOf is transparent for transport but `git remote get-url origin` gives you the
# replacement, so _get_origin_url() sees file://$SERVE_REPO and _is_fork() would return true.
# we check for the arguments "remote get-url origin" in order in any position
# to allow for e.g. -c with some config being passed.
# if we didn't do this, we'd need the .skip_upstream_prompt file to prevent a hang in headless,"add the
# official repo as upstream?" prompt would hang a headless run. But we don't anymore :D
REAL_GIT="$(command -v git)"
REAL_GIT_QUOTED="$(printf '%q' "$REAL_GIT")"
SHIM_DIR="$WORK_ROOT/shim"
mkdir -p "$SHIM_DIR"
cat > "$SHIM_DIR/git" <<EOF
#!/usr/bin/env bash
prev2=""
prev1=""
for arg in "\$@"; do
if [ "\$prev2" = "remote" ] && [ "\$prev1" = "get-url" ] && [ "\$arg" = "origin" ]; then
echo "$REPO_URL_HTTPS"
exit 0
fi
prev2="\$prev1"
prev1="\$arg"
done
exec "$REAL_GIT_QUOTED" "\$@"
EOF
chmod +x "$SHIM_DIR/git"
export PATH="$SHIM_DIR:$PATH"
# check it worked
observed_git_url="$(git -C "$REPO_ROOT" remote get-url origin)"
if [[ "$observed_git_url" != "$REPO_URL_HTTPS" ]]; then
fail "failed git remote get-url shim: origin resolves to '$observed_git_url', expected '$REPO_URL_HTTPS'"
fi
ok "git remote get-url shim: $SHIM_DIR/git -> $REAL_GIT (origin reports $REPO_URL_HTTPS)"
}
# later, we might factor this out into a separate step like the macos desktop one.
arm_redirect
# Isolated HOME: the runner's real one may carry a preinstalled hermes or a
# developer config, and old installer scripts hardcode $HOME/.hermes (the
# HERMES_HOME env override is newer than tags we sample). GIT_CONFIG_GLOBAL
# above keeps working -- an explicit path wins over $HOME/.gitconfig.
export HOME="$WORK_ROOT/home"
mkdir -p "$HOME/.local/bin"
export PATH="$HOME/.local/bin:$PATH"
export HERMES_HOME="$HOME/.hermes"
mkdir -p "$HERMES_HOME"
INSTALL_DIR="$HERMES_HOME/hermes-agent"
# Does the installer script at REF accept FLAG? Read that ref's own
# install.sh rather than assuming this checkout's flag set: the point of the
# matrix is to install releases from months back, whose installers predate
# options we take for granted.
#
# Buffered through a variable, NOT `git show | grep -q`: under pipefail,
# grep -q exits at the first match (install.sh is ~140KB, the flags appear
# in the first few KB), git show takes SIGPIPE on its next write, and the
# pipeline reports 141 -- the probe answers NO for a flag the ref HAS.
installer_supports() {
local text
text="$(git -C "$REPO_ROOT" show "$1:scripts/install.sh")"
grep -qF -- "$2" <<< "$text"
}
run_installer() {
# $1: ref whose scripts/install.sh to run; $2: log name; $3: "desktop" to
# opt the desktop stage in (--include-desktop)
local script="$WORK_ROOT/install-$2.sh"
git -C "$REPO_ROOT" show "$1:scripts/install.sh" > "$script"
chmod +x "$script"
# Installer flags have to match the installer being run, not this
# checkout's: older releases reject options added later. --skip-setup goes
# back further than any tag we sample; anything newer is probed for.
local flags=(--skip-setup)
if installer_supports "$1" "--skip-browser"; then
flags+=(--skip-browser)
fi
if [ "${3:-}" = "desktop" ]; then
# The desktop stage is the point of this leg, so a ref without the
# flag is a hard failure, not a silent downgrade to a plain install.
# (Releases that predate apps/desktop are already skipped upstream by
# the tag-has-desktop gate; the flag shipped with the app.)
installer_supports "$1" "--include-desktop" \
|| fail "ref $1 does not support --include-desktop; this leg cannot mean what it claims"
flags+=(--include-desktop)
fi
# </dev/null: the script reads prompts from stdin when a tty is absent;
# EOF makes every remaining prompt take its default.
local rc=0
bash "$script" "${flags[@]}" < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/install-$2.log" || rc=$?
log_group "install.sh ($2) transcript" "$LOG_DIR/install-$2.log"
[ "$rc" -eq 0 ] || fail "install.sh ($2) exited $rc; transcript above, log at $LOG_DIR/install-$2.log"
}
assert_desktop_artifact() {
# $1: label. After a +desktop install the built app must exist under the
# checkout -- install.sh builds it there and registers no OS entry point.
local release_dir="$INSTALL_DIR/apps/desktop/release"
local found=""
local cand
for cand in \
"$release_dir/linux-unpacked/Hermes" \
"$release_dir/linux-unpacked/hermes" \
"$release_dir/mac-arm64/Hermes.app" \
"$release_dir/mac/Hermes.app"; do
if [ -x "$cand" ] || [ -d "$cand" ]; then
found="$cand"
break
fi
done
[ -n "$found" ] || fail "no desktop app under $release_dir after $1 (+desktop install)"
ok "desktop app built by installer at $1: $found"
}
assert_checkout() {
# $1: expected sha, $2: label
local got
got="$(git -C "$INSTALL_DIR" rev-parse HEAD)"
[ "$got" = "$1" ] || fail "installed checkout is $got, expected $2 ($1)"
ok "checkout is $2 ($1)"
local hermes="$INSTALL_DIR/venv/bin/hermes"
[ -x "$hermes" ] || fail "no hermes console script at $hermes"
"$hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-$2.log" \
|| fail "hermes --version failed after $2; log in $LOG_DIR/version-$2.log"
ok "hermes --version works: $(head -c 120 "$LOG_DIR/version-$2.log" | tr -d '\n')"
}
smoke_desktop() {
# $1: label (old|head). Prove the installed CLI can produce the desktop
# app: `hermes desktop --build-only` runs the full desktop pipeline
# (workspace install, renderer build, stamp write) and stops before the
# launch -- the same call `hermes update` itself makes. Probe the
# INSTALLED hermes for the flag rather than assuming this checkout's
# surface: sampled OLD releases may predate `hermes desktop` or
# --build-only entirely, and for them the phase skips, loudly.
local hermes="$INSTALL_DIR/venv/bin/hermes"
if ! "$hermes" desktop --help 2>/dev/null | grep -qF -- --build-only; then
ok "hermes desktop --build-only not supported at $1; skipping desktop smoke"
return 0
fi
local rc=0
(cd "$INSTALL_DIR" && "$hermes" desktop --build-only < /dev/null 2>&1 \
| ts_prefix > "$LOG_DIR/desktop-smoke-$1.log") || rc=$?
log_group "hermes desktop --build-only ($1) transcript" "$LOG_DIR/desktop-smoke-$1.log"
[ "$rc" -eq 0 ] || fail "hermes desktop --build-only ($1) exited $rc; transcript above"
ok "hermes desktop --build-only works at $1"
# TODO(launch): LAUNCH the built app and auto-close it. Mechanism when
# the pieces land: driver-side spawn interception (a sitecustomize.py on
# PYTHONPATH wraps subprocess.run under an env-var opt-in and captures
# the real argv/cwd/env at the spawn site) + Playwright _electron.launch
# on the captured spec; electronApp.close() is the auto-close. Blocked
# on that asset and, for linux runners, on a virtual display (Xvfb).
}
# --- install OLD ---------------------------------------------------------------
step "installing OLD ($INSTALL_REF) via its own scripts/install.sh ($INSTALL_METHOD)"
if [ "$INSTALL_METHOD" = "installer-script+desktop" ]; then
run_installer "$OLD_SHA" old desktop
assert_checkout "$OLD_SHA" OLD
assert_desktop_artifact OLD
else
run_installer "$OLD_SHA" old
assert_checkout "$OLD_SHA" OLD
fi
smoke_desktop old
# --- update OLD -> HEAD ----------------------------------------------------------
step "advancing served main to HEAD"
git -C "$SERVE_REPO" update-ref refs/heads/main "$HEAD_SHA"
ok "serve.git main = $HEAD_SHA"
step "updating via $UPDATE_METHOD"
case "$UPDATE_METHOD" in
hermes-update)
# `--yes` reaches the update subcommand only in later releases, and
# argparse rejects the whole invocation when it does not exist. Ask the
# installed hermes; older ones read the prompt from stdin, so close it.
HERMES="$INSTALL_DIR/venv/bin/hermes"
if "$HERMES" update --help 2>&1 | grep -qF -- --yes; then
update_cmd=("$HERMES" update --yes)
else
update_cmd=("$HERMES" update)
fi
rc=0
(cd "$INSTALL_DIR" && "${update_cmd[@]}" < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/update.log") || rc=$?
log_group "hermes update transcript" "$LOG_DIR/update.log"
[ "$rc" -eq 0 ] || fail "hermes update exited $rc; transcript above, log at $LOG_DIR/update.log"
;;
installer-script)
# A user re-running the one-liner today gets the CURRENT script.
run_installer "$HEAD_SHA" head
;;
installer-script+desktop)
run_installer "$HEAD_SHA" head desktop
assert_desktop_artifact HEAD
;;
hermes-desktop-app-update)
# The real user surface: `hermes desktop` launches the app, the user
# clicks Settings -> About -> Update now. Playwright must OWN the spawn
# (it needs the inspection pipe), so the driver intercepts the product's
# own launch call - argv/cwd/env captured at the spawn site by
# e2e-assets/launch-capture/sitecustomize.py - and re-executes it under
# _electron.launch. Everything before the spawn (build, stamps, sandbox
# fixup) runs for real in the installed code.
HERMES="$INSTALL_DIR/venv/bin/hermes"
ASSETS="$REPO_ROOT/tests/install/e2e-assets"
SPEC="$WORK_ROOT/launch-spec.json"
# A REAL configured provider: the mock inference server (the desktop E2E
# suite's own) is configured into HERMES_HOME exactly like the dev:mock
# flow does. The app then boots genuinely configured - no onboarding
# overlay (a fullscreen div that intercepts every click) - and the chat
# surface is real too.
source "$ASSETS/mock-provider.sh"
mock_start "$WORK_ROOT"
trap mock_stop EXIT
step "capturing the hermes desktop launch spec (build runs for real)"
rc=0
(cd "$INSTALL_DIR" && \
PYTHONPATH="$ASSETS/launch-capture${PYTHONPATH:+:$PYTHONPATH}" \
HERMES_E2E_CAPTURE_LAUNCH="$SPEC" \
"$HERMES" desktop < /dev/null 2>&1 | ts_prefix > "$LOG_DIR/desktop-launch-capture.log") || rc=$?
log_group "hermes desktop (launch capture) transcript" "$LOG_DIR/desktop-launch-capture.log"
[ "$rc" -eq 0 ] || fail "hermes desktop exited $rc during launch capture; transcript above"
# Exit 0 without a capture means a version that never reached its
# launch - that must fail loudly, not pass as a no-op.
[ -f "$SPEC.captured" ] || fail "hermes desktop exited 0 but no launch was captured at $SPEC"
ok "captured $(cat "$SPEC.captured") launch spec"
step "driving the app under Playwright: Settings -> About -> Update now"
# Driver tooling comes from the driver: a scratch dir with our own
# pinned @playwright/test, never resolved from the installed tree
# (older OLD refs predate the dependency; hoisting moves it around).
PW_DIR="$WORK_ROOT/playwright"
mkdir -p "$PW_DIR"
(cd "$PW_DIR" && npm install --no-save --no-audit --no-fund \
"@playwright/test@1.58.2" 2>&1 | ts_prefix > "$LOG_DIR/playwright-install.log") \
|| { log_group "playwright install transcript" "$LOG_DIR/playwright-install.log"; fail "playwright install failed"; }
cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$PW_DIR/"
rc=0
(cd "$PW_DIR" && node launch-from-spec.mjs \
--spec "$SPEC" \
--result "$HERMES_HOME/.hermes-update-result.json" \
--expect-sha "$HEAD_SHA" \
--repo-dir "$INSTALL_DIR" 2>&1 \
| ts_prefix > "$LOG_DIR/app-update.log") || rc=$?
log_group "app update (Playwright) transcript" "$LOG_DIR/app-update.log"
[ "$rc" -eq 0 ] || fail "app-driven update exited $rc; transcript above"
# The in-app update spawns a DETACHED npm/updater whose parent chain does
# not pass through the Electron root, so the driver's descendant sweep
# cannot see it and a pre-clean can race a still-writing npm.
# Deterministic quiesce instead: find processes whose cwd is inside
# $INSTALL_DIR, wait for them to finish (they are the updater's tail),
# then escalate TERM -> KILL. cwd matching is precise to this sandbox;
# no name patterns.
step "quiescing $INSTALL_DIR before the head desktop smoke"
procs_in_install_dir() {
# Linux: /proc cwd links (fast, no tools needed). Darwin has no /proc:
# one lsof pass over ALL cwd descriptors, filtered by prefix in the
# reader. Deliberately NOT `+D "$INSTALL_DIR"`: lsof exits 1 when a +D
# match comes up empty, and under `set -euo pipefail` that non-zero
# kills the leg at the assignment. The unanchored form always matches
# other processes, so empty-for-OUR-dir is exit 0.
if [ -d /proc ]; then
local pid cwd
for pid in /proc/[0-9]*; do
cwd="$(readlink "$pid/cwd" 2>/dev/null)" || continue
case "$cwd" in "$INSTALL_DIR"*) echo "${pid#/proc/}";; esac
done
else
lsof -d cwd -F pn 2>/dev/null | awk -v dir="$INSTALL_DIR" '
/^p/ { pid = substr($0, 2) }
/^n/ { if (index(substr($0, 2), dir) == 1) print pid }'
fi
}
# If the probe mechanism itself is broken (no lsof on the runner, output
# shape surprise), say so and skip the wait... a blind quiesce must be
# VISIBLE, not a vacuous "install dir quiet".
if [ ! -d /proc ] && ! command -v lsof >/dev/null 2>&1; then
echo "WARNING: no /proc and no lsof; quiesce is blind, proceeding on the pre-clean alone"
else
quiesce_deadline=$((SECONDS + 60))
while :; do
lingering="$(procs_in_install_dir || true)"
[ -z "$lingering" ] && { ok "install dir quiet"; break; }
if [ "$SECONDS" -ge "$quiesce_deadline" ]; then
echo "install-dir processes still alive after 60s; terminating: $lingering"
kill $lingering 2>/dev/null || true
sleep 5
lingering="$(procs_in_install_dir || true)"
[ -n "$lingering" ] && kill -9 $lingering 2>/dev/null || true
ok "install dir force-quieted"
break
fi
sleep 2
done
fi
# The smoke check rebuilds from scratch anyway; give it a pristine tree
# rather than whatever the interrupted in-app update left behind.
step "clearing node_modules after driver-killed in-app update"
find "$INSTALL_DIR" -maxdepth 3 -name node_modules -type d -prune -print0 2>/dev/null \
| xargs -0 rm -rf 2>/dev/null || true
ok "node_modules cleared for the head desktop smoke"
;;
esac
# Install-side state BEFORE the post-update assertions: on app-update legs
# the updater's transcript is streamed into the app UI (or runs detached)
# and is otherwise lost, so snapshot every place it also lands — product
# logs, update hand-off files, the venv's entry-point dir — while the
# install is still there to inspect. The assertions below can `fail` out
# of the driver; the evidence must already be on disk when they do.
ildest="$LOG_DIR/install-logs"
mkdir -p "$ildest"
cp -R "$HERMES_HOME/logs" "$ildest/hermes-logs" 2>/dev/null || true
if [ -n "${XDG_DATA_HOME:-}" ]; then
cp -R "$XDG_DATA_HOME/hermes/logs" "$ildest/desktop-userdata-logs" 2>/dev/null || true
fi
cp "$HERMES_HOME/.hermes-update-result.json" "$ildest" 2>/dev/null || true
ls -la "$HERMES_HOME" > "$ildest/hermes-home-ls.txt" 2>/dev/null || true
ls -la "$INSTALL_DIR/venv/bin" > "$ildest/venv-bin-ls.txt" 2>/dev/null || true
ok "collected install-side logs to $ildest"
assert_checkout "$HEAD_SHA" HEAD
smoke_desktop head
step "PASS: $INSTALL_REF -> HEAD via $UPDATE_METHOD"