Files
omarchy/test/shell.d/hermes-desktop-install-test.sh
T
72608aed85 Find a busy git in the runtime by its path as well as its name
The wait before clearing a stale shallow.lock looked for a git whose command line began with `git`, so one started as `/usr/bin/git`, which is what a caller that resolved it with `which` runs, was never seen: its lock, once a minute old, would have been cleared under it and a second fetch started against the same shallow file. The path is allowed only ahead of the name, at the start of the command line, so a process that merely names git in an argument, an editor opened on /usr/bin/git from inside the runtime say, is not waited for.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:42:26 -05:00

592 lines
33 KiB
Bash

#!/bin/bash
set -euo pipefail
source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh"
for command in git jq python3; do require_command "$command"; done
test_tmp=$(mktemp -d)
trap 'rm -rf -- "$test_tmp"' EXIT
export OMARCHY_TEST_ROOT="$test_tmp"
mkdir -p "$test_tmp/bin" "$test_tmp/package/resources" "$test_tmp/share" "$test_tmp/seed"
# Real Git exercises patch checks and preservation; all package, desktop and
# service commands are mocks. No command reaches the live user installation.
git -C "$test_tmp/seed" init -q -b main
printf 'venv/\n.hermes-bootstrap-complete\napps/desktop/release/\n__pycache__/\n' >"$test_tmp/seed/.gitignore"
printf 'before\n' >"$test_tmp/seed/runtime.txt"
mkdir -p "$test_tmp/seed/apps/desktop/src"
printf 'desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
mkdir -p "$test_tmp/seed/hermes_cli"
cat >"$test_tmp/seed/hermes_cli/main.py" <<'PY'
import os
from pathlib import Path
def _write_desktop_build_stamp(project_root, *, source_mode):
home = Path(os.environ['HERMES_HOME'])
assert project_root == home / 'hermes-agent'
assert source_mode is False
assert (project_root / 'apps/desktop/release/linux-unpacked/resources/app.asar').is_file()
(home / 'desktop-build-stamp.json').write_text('upstream build stamp')
with (Path(os.environ['OMARCHY_TEST_ROOT']) / 'events').open('a') as log:
log.write('build-stamp\n')
PY
git -C "$test_tmp/seed" add .
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm fixture
release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
printf 'after\n' >"$test_tmp/seed/runtime.txt"
git -C "$test_tmp/seed" diff >"$test_tmp/share/runtime.patch"
printf 'before\n' >"$test_tmp/seed/runtime.txt"
printf 'newer desktop source\n' >"$test_tmp/seed/apps/desktop/src/main.js"
git -C "$test_tmp/seed" add apps/desktop/src/main.js
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm newer-main
origin_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
export OMARCHY_TEST_RELEASE_COMMIT="$release_commit"
printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json"
printf 'packaged app\n' >"$test_tmp/package/resources/app.asar"
printf '#!/bin/bash\nexit 0\n' >"$test_tmp/package/Hermes"
touch "$test_tmp/package/chrome-sandbox"
chmod 755 "$test_tmp/package/Hermes"
chmod 4755 "$test_tmp/package/chrome-sandbox"
cat >"$test_tmp/share/install.sh" <<'MOCK'
#!/bin/bash
set -e
printf '%s\n' "$@" >"$OMARCHY_TEST_ROOT/install-args"
printf '%s\n' "${npm_config_yes:-unset}" >"$OMARCHY_TEST_ROOT/install-npx-answer"
[[ ${OMARCHY_TEST_INSTALL_FAIL:-0} != 1 ]] || exit 7
commit=$OMARCHY_TEST_RELEASE_COMMIT
force=false
stage=""
while (( $# )); do
case "$1" in
--dir) runtime=$2; shift ;;
--commit) commit=$2; shift ;;
--force-commit) force=true ;;
--stage) stage=$2; shift ;;
--hermes-home) [[ $2 == "$HERMES_HOME" ]] ;;
esac
shift
done
# The commands upstream writes last: shims for the two side commands and, for
# hermes, a launcher into the runtime's venv the way the real one is written.
write_commands() {
mkdir -p "$HOME/.local/bin"
for command in hermes hermes-agent hermes-acp; do
rm -f "$HOME/.local/bin/$command"
printf 'native runtime shim\n' >"$HOME/.local/bin/$command"
done
printf '#!/bin/bash\nexec "%s/venv/bin/hermes" "$@"\n' "$runtime" >"$HOME/.local/bin/hermes"
chmod +x "$HOME/.local/bin/hermes"
}
# The path stage writes the commands alone, without touching the checkout.
if [[ $stage == "path" ]]; then
printf 'path\n' >>"$OMARCHY_TEST_ROOT/events"
write_commands
exit 0
fi
printf 'bootstrap\n' >>"$OMARCHY_TEST_ROOT/events"
mkdir -p -- "${runtime%/*}"
if [[ ! -d $runtime ]]; then
git clone -q --depth 1 "file://$OMARCHY_TEST_ROOT/seed" "$runtime"
else
git -C "$runtime" checkout -q main
git -C "$runtime" pull -q --ff-only origin main
fi
git -C "$runtime" fetch -q origin "$commit"
if [[ $force == true ]] || ! git -C "$runtime" merge-base --is-ancestor "$commit" HEAD; then
git -C "$runtime" checkout -q --detach "$commit"
fi
mkdir -p "$runtime/venv/bin"
git -C "$runtime" rev-parse HEAD >"$runtime/venv/dependency-commit"
# The venv command answers the readiness probes the way the real one does: the
# installer runs the command it leaves on PATH before calling Hermes ready.
cat >"$runtime/venv/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-test"
fi
SH
chmod +x "$runtime/venv/bin/hermes"
printf '#!/bin/bash\nexec /usr/bin/python3 "$@"\n' >"$runtime/venv/bin/python"
chmod +x "$runtime/venv/bin/python"
[[ ${OMARCHY_TEST_NO_MARKER:-0} == 1 ]] || touch "$runtime/.hermes-bootstrap-complete"
write_commands
MOCK
cat >"$test_tmp/bin/omarchy-pkg-add" <<'MOCK'
#!/bin/bash
printf 'package %s\n' "$*" >>"$OMARCHY_TEST_ROOT/events"
[[ ${OMARCHY_TEST_PACKAGE_FAIL:-0} != 1 ]] || exit 1
touch "$OMARCHY_TEST_ROOT/package-installed"
MOCK
cat >"$test_tmp/bin/omarchy-pkg-present" <<'MOCK'
#!/bin/bash
[[ -e $OMARCHY_TEST_ROOT/package-installed ]]
MOCK
cat >"$test_tmp/bin/git" <<'MOCK'
#!/bin/bash
if [[ ${OMARCHY_TEST_FETCH_FAIL:-0} == 1 && " $* " == *" --unshallow "* ]]; then exit 8; fi
exec /usr/bin/git "$@"
MOCK
cat >"$test_tmp/bin/setsid" <<'MOCK'
#!/bin/bash
exec "$@"
MOCK
cat >"$test_tmp/bin/cp" <<'MOCK'
#!/bin/bash
if [[ ${OMARCHY_TEST_COPY_FAIL:-0} == 1 ]]; then
touch "${@: -1}/partial-copy"
exit 9
fi
exec /usr/bin/cp "$@"
MOCK
cat >"$test_tmp/bin/mv" <<'MOCK'
#!/bin/bash
if [[ ${OMARCHY_TEST_COPY_RACE:-0} == 1 && $1 == -T ]]; then
mkdir -p "${@: -1}"
fi
exec /usr/bin/mv "$@"
MOCK
cat >"$test_tmp/bin/uwsm-app" <<'MOCK'
#!/bin/bash
[[ $1 == -- ]] || exit 1
shift
exec "$@"
MOCK
cat >"$test_tmp/bin/hermes-desktop" <<'MOCK'
#!/bin/bash
sleep 0.05
native="$HERMES_HOME/hermes-agent/apps/desktop/release/linux-unpacked"
if [[ -x $native/Hermes && -f $native/resources/app.asar ]]; then
printf 'launch\n' >>"$OMARCHY_TEST_ROOT/events"
else
printf 'launch-before-copy\n' >>"$OMARCHY_TEST_ROOT/events"
fi
MOCK
cat >"$test_tmp/bin/systemctl" <<'MOCK'
#!/bin/bash
printf 'theme-stop\n' >>"$OMARCHY_TEST_ROOT/events"
MOCK
cat >"$test_tmp/bin/systemd-run" <<'MOCK'
#!/bin/bash
printf 'theme-start\n' >>"$OMARCHY_TEST_ROOT/events"
MOCK
chmod +x "$test_tmp/bin/"*
# Substitute only system package paths in scratch copies of the actual scripts.
# The runtime setup lives in omarchy-install-hermes-cli, which the desktop
# installer finds on PATH under its own name.
python3 - "$ROOT/bin" "$test_tmp" <<'PY'
from pathlib import Path
import sys
source, scratch = Path(sys.argv[1]), Path(sys.argv[2])
substitutions = {
'/opt/hermes-desktop': str(scratch / 'package'),
'/usr/share/hermes-desktop': str(scratch / 'share'),
'/usr/bin/hermes-desktop': str(scratch / 'bin/hermes-desktop'),
}
for name, target in (('omarchy-install-ai-hermes', scratch / 'installer'),
('omarchy-install-hermes-cli', scratch / 'bin/omarchy-install-hermes-cli')):
script = (source / name).read_text()
for original, replacement in substitutions.items():
script = script.replace(original, replacement)
target.write_text(script)
target.chmod(0o755)
PY
new_home() {
test_home="$test_tmp/$1"
hermes_home="$test_home/.hermes"
runtime="$hermes_home/hermes-agent"
native="$runtime/apps/desktop/release/linux-unpacked"
mkdir -p "$test_home"
rm -f "$test_tmp/package-installed"
: >"$test_tmp/events"
}
# The app opens in the background, so a run that got that far is joined to it
# before anything is asserted; one that stopped earlier started nothing.
run_installer() {
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$test_home/.local/bin:$PATH" npm_config_yes= \
bash "$test_tmp/installer" >"$test_tmp/output" 2>&1 || return
for (( attempt=0; attempt<200; attempt++ )); do
if grep -q '^launch' "$test_tmp/events"; then return 0; fi
sleep 0.01
done
return 1
}
# ~/.local/bin is on PATH the way Omarchy puts it there, after the mocks.
run_cli() {
HOME="$test_home" HERMES_HOME="${OMARCHY_TEST_HOME:-$hermes_home}" PATH="$test_tmp/bin:$test_home/.local/bin:$PATH" npm_config_yes= \
bash "$test_tmp/bin/omarchy-install-hermes-cli" "$@" >"$test_tmp/output" 2>&1
}
assert_stopped() {
if grep -Eq '^(launch|theme-|build-stamp)' "$test_tmp/events"; then fail "$1"; fi
}
new_home fresh
run_installer || fail "fresh setup succeeds" "$(cat "$test_tmp/output")"
expected=$(printf '%s\n' --skip-setup --branch main --commit "$release_commit" --force-commit --dir "$runtime" --hermes-home "$hermes_home")
[[ $(cat "$test_tmp/install-args") == "$expected" ]] || fail "upstream installer receives the pinned main arguments"
# npx asks before fetching a package it does not have, Playwright's included,
# and the floating terminal the menu opens has nobody to answer. The runners
# clear the variable, so only the installer can have set it.
[[ $(cat "$test_tmp/install-npx-answer") == "true" ]] || fail "upstream installer runs with npx's question answered" "$(cat "$test_tmp/install-npx-answer")"
[[ $(head -2 "$test_tmp/events") == $'package hermes-desktop\nbootstrap' ]] || fail "the package precedes runtime bootstrap" "$(cat "$test_tmp/events")"
[[ $(sed -n '3p' "$test_tmp/events") == build-stamp ]] || fail "upstream build stamp follows the app copy" "$(cat "$test_tmp/events")"
[[ $(tail -1 "$test_tmp/events") == launch ]] || fail "the app opens only once setup and the theme hand-over are in place" "$(cat "$test_tmp/events")"
grep -qx theme-start "$test_tmp/events" || fail "setup hands Hermes the theme"
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "the upstream helper records the completed packaged build"
[[ $(cat "$runtime/runtime.txt") == after ]] || fail "the release runtime receives its patch"
[[ $(stat -c %a "$native/chrome-sandbox") == 755 ]] || fail "the user sandbox is not setuid"
[[ $(stat -c %a "$test_tmp/package/chrome-sandbox") == 4755 ]] || fail "package sandbox permissions remain unchanged"
[[ $(git -C "$runtime" symbolic-ref --short HEAD) == main && $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release rather than the clone tip"
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "dependencies are installed for the release"
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "first update has connected history"
# Reproduce the updater's checkout/count/pull sequence while origin stays put.
git clone -q "$runtime" "$test_tmp/first-update"
git -C "$test_tmp/first-update" remote set-url origin "file://$test_tmp/seed"
git -C "$test_tmp/first-update" fetch -q origin main
git -C "$test_tmp/first-update" checkout -q main
[[ $(git -C "$test_tmp/first-update" rev-list HEAD..origin/main --count) == 1 ]] || fail "first update detects work even when origin has not moved since install"
git -C "$test_tmp/first-update" pull -q --ff-only origin main
[[ $(git -C "$test_tmp/first-update" rev-parse HEAD) == "$origin_commit" ]] || fail "first update fast-forwards to origin"
pass "fresh setup pins main, patches the matching runtime and copies the complete app before launch"
printf 'user app\n' >"$native/resources/app.asar"
printf 'user build stamp\n' >"$hermes_home/desktop-build-stamp.json"
: >"$test_tmp/events"
run_installer || fail "repeat setup succeeds" "$(cat "$test_tmp/output")"
! grep -qx bootstrap "$test_tmp/events" || fail "repeat setup does not bootstrap again"
! grep -qx build-stamp "$test_tmp/events" || fail "existing app never reruns the build stamp writer"
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'user build stamp' ]] || fail "existing native build stamp remains unchanged"
[[ $(cat "$native/resources/app.asar") == 'user app' ]] || fail "existing native app remains unchanged"
pass "repeat setup accepts the applied patch and preserves the existing native app"
# Advancing the runtime must never reinstall the release or reapply its patch.
printf 'new main\n' >"$runtime/runtime.txt"
git -C "$runtime" add runtime.txt
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm update
: >"$test_tmp/events"
run_installer || fail "a complete updated runtime and native app are reused"
[[ $(cat "$runtime/runtime.txt") == 'new main' ]] || fail "updated runtime is not release-patched"
mv "$native" "$test_tmp/saved-native"
: >"$test_tmp/events"
run_installer && fail "a newer runtime cannot receive an older native app"
[[ ! -e $native ]] || fail "no mismatched native app was copied"
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "missing newer native app has actionable guidance"
assert_stopped "a missing updated app prevents launch and theme setup"
pass "updated runtimes are preserved and never seeded with the old packaged app"
new_home dirty-desktop
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'local desktop edit\n' >"$runtime/apps/desktop/src/main.js"
: >"$test_tmp/events"
run_installer && fail "modified desktop sources cannot be certified as the packaged build"
[[ ! -e $native && ! -e $hermes_home/desktop-build-stamp.json ]] || fail "modified desktop sources receive neither packaged app nor build stamp"
[[ $(cat "$runtime/apps/desktop/src/main.js") == 'local desktop edit' ]] || fail "desktop source edits are preserved"
grep -q 'hermes desktop --build-only' "$test_tmp/output" || fail "modified desktop sources have build guidance"
assert_stopped "modified desktop sources prevent stamping, launch and theme setup"
pass "a matching commit with modified desktop sources is preserved without seeding or stamping"
for failure in package install marker; do
new_home "$failure-failure"
case "$failure" in
package) OMARCHY_TEST_PACKAGE_FAIL=1 run_installer && fail "package failure stops setup" ;;
install) OMARCHY_TEST_INSTALL_FAIL=1 run_installer && fail "installer failure stops setup" ;;
marker) OMARCHY_TEST_NO_MARKER=1 run_installer && fail "missing marker stops setup" ;;
esac
[[ ! -e $native ]] || fail "failed setup does not seed the app"
assert_stopped "failed setup prevents launch and theme setup"
done
pass "package, upstream installer and readiness failures stop before launch"
for failure in copy race; do
new_home "$failure-failure"
if [[ $failure == "copy" ]]; then
OMARCHY_TEST_COPY_FAIL=1 run_installer && fail "copy failure stops setup"
[[ ! -e $native ]] || fail "partial copy is never published"
else
OMARCHY_TEST_COPY_RACE=1 run_installer && fail "concurrent native app stops publication"
[[ -d $native && -z $(ls -A "$native") ]] || fail "concurrent empty app directory is preserved"
fi
[[ -z $(find "${native%/*}" -maxdepth 1 -name '.linux-unpacked.*' -print) ]] || fail "owned staging directory is cleaned up"
assert_stopped "publication failure prevents launch"
done
pass "failed copies and concurrent app creation preserve existing work and clean only staging"
new_home incomplete-native
run_installer || fail "incomplete native fixture sets up"
rm "$native/resources/app.asar"
: >"$test_tmp/events"
run_installer && fail "incomplete existing app requires repair"
[[ ! -e $native/resources/app.asar ]] || fail "incomplete existing app is not overwritten"
assert_stopped "incomplete native app prevents launch"
pass "an incomplete existing native app is preserved"
# A runtime already at the release, edited where the patch lands, before Omarchy
# has prepared it: the conflict is reported and nothing is touched.
new_home patch-conflict
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'local edit\n' >"$runtime/runtime.txt"
: >"$test_tmp/events"
run_installer && fail "unexpected patch conflict stops setup"
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "conflicting runtime changes are preserved"
assert_stopped "patch conflict prevents launch"
rm "$runtime/.hermes-bootstrap-complete"
: >"$test_tmp/events"
run_installer && fail "incomplete modified runtime cannot be reset by upstream installer"
! grep -qx bootstrap "$test_tmp/events" || fail "modified runtime never reaches upstream installer"
pass "patch conflicts and incomplete modified runtimes retain local changes and stop safely"
# Once set up, a runtime is the user's to edit; a finished install is not
# re-patched or re-verified, only opened.
new_home finished-edit
run_installer || fail "finished-edit fixture sets up" "$(cat "$test_tmp/output")"
printf 'local edit\n' >"$runtime/runtime.txt"
: >"$test_tmp/events"
run_installer || fail "a finished install with local edits is accepted" "$(cat "$test_tmp/output")"
[[ $(cat "$runtime/runtime.txt") == 'local edit' ]] || fail "local edits to a finished runtime are preserved"
[[ $(cat "$test_tmp/events") == $'package hermes-desktop\nlaunch' ]] || fail "a finished install is only opened" "$(cat "$test_tmp/events")"
pass "a finished install is left as the user has it"
new_home full-history-retry
git clone -q "$test_tmp/seed" "$runtime"
git -C "$runtime" checkout -q --detach "$release_commit"
run_installer || fail "clean incomplete full-history release checkout is repaired" "$(cat "$test_tmp/output")"
[[ $(cat "$runtime/venv/dependency-commit") == "$release_commit" ]] || fail "full-history retry pins before installing dependencies"
[[ $(git -C "$runtime" rev-parse HEAD) == "$release_commit" && -f $native/resources/app.asar ]] || fail "full-history retry seeds the matching release"
pass "full-history retries force the guarded release pin before dependency setup"
# A main that is neither the release nor origin/main is kept under another name
# and main still starts at the release: a user's own commits stay reachable,
# and a clone whose main is off origin/main only because upstream rewrote its
# history is not refused for work it never did.
new_home local-main
git clone -q "$test_tmp/seed" "$runtime"
printf 'local branch work\n' >"$runtime/keep"
git -C "$runtime" add keep
git -C "$runtime" -c user.name=Test -c user.email=test@example.invalid commit -qm local-work
local_main=$(git -C "$runtime" rev-parse main)
git -C "$runtime" checkout -q --detach "$release_commit"
run_installer || fail "a runtime whose main carries other work still sets up" "$(cat "$test_tmp/output")"
[[ $(git -C "$runtime" rev-parse main) == "$release_commit" ]] || fail "main starts at the release"
kept=$(git -C "$runtime" for-each-ref --format='%(objectname)' 'refs/heads/main-before-omarchy-*')
[[ $kept == "$local_main" ]] || fail "what main pointed at is kept under another name" "$kept"
grep -q 'main-before-omarchy-' "$test_tmp/output" || fail "the kept branch is named in the output"
[[ -f $native/resources/app.asar ]] || fail "setup carries on to seed the app"
pass "work on main is kept under another name rather than refused"
# A shallow.lock nothing has touched for a minute is what a probe that killed
# Hermes mid-fetch leaves behind; it must not stop the history fetch for good.
new_home stale-lock
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'stale\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
run_installer || fail "a stale shallow.lock does not stop setup" "$(cat "$test_tmp/output")"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the stale lock is cleared"
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "the history fetch went ahead after the stale lock"
pass "a stale shallow.lock is cleared rather than left to block every history fetch"
# The same old lock with a git still working in the runtime is somebody's: it
# is waited for, not taken. Each way a live git is found gets its own run: a
# stand-in git that lives until this test releases it, only after setup has
# said it is waiting, and that says if its lock was stolen while it lived; it
# then leaves the lock behind orphaned the way a killed fetch would.
live_lock_case() {
local name=$1 where=$2 output_line='Waiting for Hermes' attempt
shift 2
new_home "$name"
ln -s "$test_home" "$test_tmp/$name-link"
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'live\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
rm -f "$test_tmp/lock-stolen" "$test_tmp/release-git"
(cd "$where" && { if (( $# )); then export "$@"; fi; } && export LOCK="$runtime/.git/shallow.lock" && exec -a "${OMARCHY_TEST_GIT_ARGV0:-git}" bash -c 'for (( i = 0; i < 900; i++ )); do [[ -e "$1" ]] && exit; [[ -e $LOCK ]] || { touch "$2"; exit; }; sleep 0.1; done' _ "$test_tmp/release-git" "$test_tmp/lock-stolen") &
fake_git=$!
: >"$test_tmp/output"
# The runtime is reached through a link, as a symlinked home would, since
# /proc reports canonical paths and the runtime path keeps links.
OMARCHY_TEST_HOME="$test_tmp/$name-link/.hermes" run_installer &
installer=$!
for (( attempt = 0; attempt < 300; attempt++ )); do
grep -q "$output_line" "$test_tmp/output" 2>/dev/null && break
sleep 0.1
done
if ! grep -q "$output_line" "$test_tmp/output"; then
touch "$test_tmp/release-git"; wait "$installer" || true
fail "setup says it is waiting for the live git ($name)" "$(cat "$test_tmp/output")"
fi
[[ ! -e $test_tmp/lock-stolen ]] || fail "the lock was cleared while a git still worked in the runtime ($name)"
touch "$test_tmp/release-git"
wait "$installer" || fail "setup goes ahead once the git is gone ($name)" "$(cat "$test_tmp/output")"
wait "$fake_git" 2>/dev/null || true
[[ ! -e $test_tmp/lock-stolen ]] || fail "the lock was cleared while a git still worked in the runtime ($name)"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the orphaned lock is cleared once nothing holds it ($name)"
[[ $(git -C "$runtime" rev-parse --is-shallow-repository) == false ]] || fail "the history fetch went ahead after the wait ($name)"
}
# Found by its working directory.
live_lock_case live-lock-cwd "$test_tmp/live-lock-cwd/.hermes/hermes-agent"
pass "a lock a live git holds is waited for, not taken, through a linked runtime path"
# Found by GIT_DIR alone, with a trailing slash, from elsewhere, with a large
# environment: read whole and NUL-delimited, or it would go unseen.
big_env=$(head -c 120000 /dev/zero | tr '\0' 'x')
live_lock_case live-lock-env "$test_tmp" BIG_ENV="$big_env" GIT_DIR="$test_tmp/live-lock-env/.hermes/hermes-agent/.git/"
pass "a git working from elsewhere with GIT_DIR naming the runtime is found by its environment"
# Named by its path, as a git run as /usr/bin/git is.
live_lock_case live-lock-path "$test_tmp/live-lock-path/.hermes/hermes-agent" OMARCHY_TEST_GIT_ARGV0=/usr/bin/git
pass "a git that names itself by its path is found too"
# A process that only names git on its command line, an editor opened on
# /usr/bin/git from inside the runtime say, is not a git at work there: the
# stale lock goes and setup carries on without waiting.
new_home bystander
HOME="$test_home" HERMES_HOME="$hermes_home" bash "$test_tmp/share/install.sh" --dir "$runtime" --hermes-home "$hermes_home"
printf 'stale\n' >"$runtime/.git/shallow.lock"
touch -d '5 minutes ago' "$runtime/.git/shallow.lock"
rm -f "$test_tmp/release-bystander"
(cd "$runtime" && exec -a nvim bash -c 'for (( i = 0; i < 900; i++ )); do [[ -e "$1" ]] && exit; sleep 0.1; done' _ "$test_tmp/release-bystander" /usr/bin/git) &
bystander=$!
run_installer || { touch "$test_tmp/release-bystander"; fail "setup goes ahead beside a process that only names git" "$(cat "$test_tmp/output")"; }
touch "$test_tmp/release-bystander"
wait "$bystander" 2>/dev/null || true
! grep -q 'Waiting for Hermes' "$test_tmp/output" || fail "a process that only names git is waited for"
[[ ! -e $runtime/.git/shallow.lock ]] || fail "the stale lock is cleared beside a process that only names git"
pass "a process that only names git on its command line is not waited for"
new_home deepen-retry
OMARCHY_TEST_FETCH_FAIL=1 run_installer && fail "history fetch failure stops setup"
[[ ! -e $native ]] || fail "failed history fetch does not seed the app"
assert_stopped "failed history fetch prevents launch"
: >"$test_tmp/events"
run_installer || fail "history fetch can be retried after runtime setup" "$(cat "$test_tmp/output")"
! grep -qx bootstrap "$test_tmp/events" || fail "history retry does not repeat upstream installation"
pass "a history fetch failure can be retried without reinstalling the ready runtime"
new_home existing-commands
mkdir -p "$test_home/.local/bin"
printf 'foreign wrapper\n' >"$test_home/.local/bin/hermes"
printf 'symlink target\n' >"$test_home/target"
ln -s "$test_home/target" "$test_home/.local/bin/hermes-agent"
ln -s "$test_home/missing" "$test_home/.local/bin/hermes-acp"
run_installer || fail "existing commands are preserved before upstream replaces them" "$(cat "$test_tmp/output")"
backups=("$test_home/.local/bin/".hermes-before-desktop.*)
[[ ${#backups[@]} == 1 && -d ${backups[0]} ]] || fail "one backup directory preserves existing command names"
[[ $(cat "${backups[0]}/hermes") == 'foreign wrapper' ]] || fail "foreign wrapper bytes are saved"
[[ $(readlink "${backups[0]}/hermes-agent") == "$test_home/target" && $(readlink "${backups[0]}/hermes-acp") == "$test_home/missing" ]] || fail "working and broken symlinks are saved as links"
[[ $(cat "$test_home/target") == 'symlink target' ]] || fail "upstream does not overwrite the original symlink target"
grep -qF "${backups[0]}" "$test_tmp/output" || fail "backup location is reported"
pass "pre-existing command files and symlinks are backed up before replacement"
new_home old-package
mv "$test_tmp/package/resources/install-stamp.json" "$test_tmp/saved-install-stamp.json"
run_installer && fail "an old installed package cannot bootstrap"
grep -q 'omarchy update' "$test_tmp/output" || fail "old package has actionable upgrade guidance"
! grep -qx bootstrap "$test_tmp/events" || fail "old package never reaches upstream installer"
mv "$test_tmp/saved-install-stamp.json" "$test_tmp/package/resources/install-stamp.json"
pass "old package fails with upgrade guidance before changing the runtime"
# Choosing Hermes as the default agent runs the same setup, short of opening
# the app: the package, the runtime, the seeded app and the theme hand-over.
new_home terminal-agent
run_cli --now || fail "the default agent path sets Hermes up" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == $'package hermes-desktop\nbootstrap\nbuild-stamp\ntheme-stop\ntheme-start' ]] ||
fail "the default agent path installs the app's runtime without opening the app" "$(cat "$test_tmp/events")"
[[ -x $test_home/.local/bin/hermes && -f $native/resources/app.asar ]] || fail "the default agent path leaves the command and the seeded app in place"
: >"$test_tmp/events"
run_cli --now || fail "a finished install is accepted by the default agent path" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/events ]] || fail "a finished install is set up again" "$(cat "$test_tmp/events")"
run_cli --check || fail "--check follows the installed runtime"
pass "choosing Hermes as the default agent installs the app's runtime without opening the app"
# A Hermes the user set up themselves is what the default agent runs, and
# nothing is installed beside it. Asked for the app by name, Omarchy installs
# the package first, and then the runtime supersedes it with the command saved.
new_home own-hermes
mkdir -p "$test_home/.local/bin"
cat >"$test_home/.local/bin/hermes" <<'SH'
#!/bin/bash
if [[ ${1:-} == "chat" && ${2:-} == "--help" ]]; then
echo "[-q QUERY, --query QUERY] [--tui]"
else
echo "hermes-agent 0.0.0-user"
fi
SH
chmod +x "$test_home/.local/bin/hermes"
own_hermes=$(cat "$test_home/.local/bin/hermes")
run_cli --now || fail "the default agent path accepts the user's own Hermes" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/events ]] || fail "a working Hermes of the user's own has the app installed beside it" "$(cat "$test_tmp/events")"
[[ $(cat "$test_home/.local/bin/hermes") == "$own_hermes" ]] || fail "the user's own hermes command is left alone"
run_installer || fail "the app installs over the user's own Hermes" "$(cat "$test_tmp/output")"
grep -qx bootstrap "$test_tmp/events" || fail "the app sets up its own runtime"
backups=("$test_home/.local/bin/".hermes-before-desktop.*)
[[ ${#backups[@]} == 1 && $(cat "${backups[0]}/hermes") == "$own_hermes" ]] || fail "the user's own hermes command is saved aside"
pass "the default agent path stands aside for the user's own Hermes; the app supersedes it"
# A finished runtime whose command is gone, or not its own, gets its command
# back from upstream's path stage alone: no bootstrap, the runtime untouched,
# and what held the name saved aside. Until then --check says no, so the menu
# opens a terminal for the repair rather than running the agent on the wrong
# Hermes.
new_home command-repair
run_cli --now || fail "command-repair fixture sets up" "$(cat "$test_tmp/output")"
rm "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls a runtime installed without its command"
: >"$test_tmp/events"
run_cli --now || fail "a missing command is restored" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path ]] || fail "a missing command is restored without bootstrapping again" "$(cat "$test_tmp/events")"
run_cli --check || fail "--check follows the restored command"
printf '%s\n' "#!/bin/bash" "exec /usr/local/bin/somebody-elses-hermes \"\$@\"" >"$test_home/.local/bin/hermes"
chmod +x "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls the app installed while the terminal is on another Hermes"
: >"$test_tmp/events"
run_cli --now || fail "a foreign command beside the app's runtime is replaced" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path ]] || fail "a foreign command is replaced without bootstrapping again" "$(cat "$test_tmp/events")"
grep -lF somebody-elses-hermes "$test_home/.local/bin/".hermes-before-desktop.*/hermes >/dev/null 2>&1 || fail "the foreign command is saved aside"
grep -qF "$hermes_home/" "$test_home/.local/bin/hermes" || fail "the runtime's own command is back"
run_cli --check || fail "--check follows the runtime's own command"
chmod -x "$test_home/.local/bin/hermes"
run_cli --check && fail "--check calls the runtime's own command installed when it cannot run"
: >"$test_tmp/events"
run_cli --now || fail "the runtime's own command is rewritten when it cannot run" "$(cat "$test_tmp/output")"
[[ $(cat "$test_tmp/events") == path && -x $test_home/.local/bin/hermes ]] || fail "a command that cannot run is rewritten by the path stage alone" "$(cat "$test_tmp/events")"
pass "a finished runtime gets its own command back without bootstrapping again"
# A hermes ahead of ~/.local/bin on PATH is what the default agent would run,
# so a finished install behind it is not installed, and --now names it rather
# than setting anything up again.
cp "$test_home/.local/bin/hermes" "$test_tmp/bin/hermes"
run_cli --check && fail "--check calls a shadowed install installed"
: >"$test_tmp/events"
run_cli --now && fail "--now reports a shadowed install as ready"
grep -qF "$test_tmp/bin/hermes" "$test_tmp/output" || fail "--now names the command in the way" "$(cat "$test_tmp/output")"
[[ ! -s $test_tmp/events ]] || fail "a shadowed install is set up again" "$(cat "$test_tmp/events")"
rm -f "$test_tmp/bin/hermes"
run_cli --check || fail "--check follows the install once nothing shadows it"
pass "a hermes ahead of ~/.local/bin on PATH is reported, not set up over"
new_home custom-profile
hermes_home="$test_home/custom home"
runtime="$hermes_home/hermes-agent"
OMARCHY_TEST_HOME="$hermes_home/PrOfIlEs/coder/../coder/" run_installer || fail "profile setup succeeds"
[[ -x $runtime/apps/desktop/release/linux-unpacked/Hermes ]] || fail "profile uses the canonical root runtime"
grep -qxF "$hermes_home" "$test_tmp/install-args" || fail "canonical custom home reaches upstream installer"
pass "custom profile paths normalize to the shared Hermes home"
# New releases moved the stamp writer out of main. Keep the earlier cases on
# the old layout and exercise a fresh installation with the relocated helper.
git -C "$test_tmp/seed" mv hermes_cli/main.py hermes_cli/main_desktop.py
printf 'raise AssertionError("legacy module imported after desktop split")\n' >"$test_tmp/seed/hermes_cli/main.py"
git -C "$test_tmp/seed" add hermes_cli/main.py
git -C "$test_tmp/seed" -c user.name=Test -c user.email=test@example.invalid commit -qm split-desktop
release_commit=$(git -C "$test_tmp/seed" rev-parse HEAD)
export OMARCHY_TEST_RELEASE_COMMIT="$release_commit"
printf '{"branch":"main","commit":"%s"}\n' "$release_commit" >"$test_tmp/package/resources/install-stamp.json"
new_home split-desktop
run_installer || fail "setup supports the relocated desktop helper" "$(cat "$test_tmp/output")"
[[ $(cat "$hermes_home/desktop-build-stamp.json") == 'upstream build stamp' ]] || fail "relocated helper writes the build stamp"
grep -qx launch "$test_tmp/events" || fail "setup launches after the relocated helper writes the stamp"
pass "new releases use the relocated desktop stamp writer"