#!/usr/bin/env bash # Prove a macOS user who installed OLD via the published desktop installer # (Hermes-Setup.dmg from the website) can reach HEAD. # # The macOS sibling of tests/install/windows-e2e.ps1's desktop-installer # arm, sharing the staging trick: every git process is pointed at a local # bare clone via url..insteadOf in a driver-owned # GIT_CONFIG_GLOBAL. The published dmg carries no commit pin - it installs # whatever `main` serves - so parking serve.git's main at OLD stages the # "user on the current release" start, and advancing it to HEAD makes an # update available exactly the way it does for a real user. # # Phases (state shared via the workroot, mirroring the windows driver): # stage bare-clone this checkout to serve.git, park main at OLD # install download the dmg, hdiutil attach, run the installer app's # binary DIRECTLY (env inheritance: an `open`-launched app sees # none of our redirect env), wait for the install to land # update advance served main to HEAD, apply ONE update method: # open-app-update launch the installed app binary # under Playwright, click Update now # hermes-desktop-app-update capture `hermes desktop`'s spawn, # launch the spec under Playwright, # click Update now # hermes-update CLI update from the installed venv # installer-script[+desktop] re-run the current install one-liner # # Usage: # tests/install/macos-desktop-e2e.sh --phase stage|install|update|all # --update-method open-app-update|hermes-desktop-app-update # [--install-ref REF] [--dmg-url URL] # # Requires a clean full-history checkout with release tags fetched, on a # macOS host with a window server (the GitHub macos runners qualify). 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 PHASE="all" UPDATE_METHOD="" INSTALL_REF="" DMG_URL="https://hermes-assets.nousresearch.com/Hermes-Setup.dmg" PLAYWRIGHT_VERSION="1.58.2" while [ "$#" -gt 0 ]; do case "$1" in --phase) [ "$#" -ge 2 ] || { echo 'error: --phase needs a value' >&2; exit 1; } PHASE="$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 ;; --dmg-url) [ "$#" -ge 2 ] || { echo 'error: --dmg-url needs a value' >&2; exit 1; } DMG_URL="$2"; shift 2 ;; -h|--help) sed -n '2,32p' "$0"; exit 0 ;; *) echo "error: unknown argument: $1" >&2; exit 1 ;; esac done case "$UPDATE_METHOD" in open-app-update|hermes-desktop-app-update|hermes-update|installer-script|installer-script+desktop) ;; *) echo "error: unsupported --update-method '$UPDATE_METHOD'" >&2; exit 1 ;; esac [ "$(uname -s)" = "Darwin" ] || { echo "error: this driver runs on macOS only" >&2; exit 1; } 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" ASSETS="$REPO_ROOT/tests/install/e2e-assets" WORK_ROOT="${HERMES_E2E_WORKROOT:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/hermes-macos-desktop-e2e}" LOG_DIR="${HERMES_E2E_LOG_DIR:-$WORK_ROOT/logs}" SERVE_REPO="$WORK_ROOT/serve.git" STATE="$WORK_ROOT/shas.env" export HOME_SANDBOX="$WORK_ROOT/home" 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; } log_group() { printf '::group::%s\n' "$1" cat "$2" printf '::endgroup::\n' } # Every phase runs in its own process (separate CI steps), so the redirect # env is re-established here, not inherited. 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" < "$SHIM_DIR/git" < $REAL_GIT (origin reports $REPO_URL_HTTPS)" # ------- export HOME="$HOME_SANDBOX" export PATH="$HOME/.local/bin:$PATH" export HERMES_HOME="$HOME/.hermes" export INSTALL_DIR="$HERMES_HOME/hermes-agent" } phase_stage() { step "staging serve.git (main -> OLD)" [ -z "$(git -C "$REPO_ROOT" status --porcelain -uno)" ] \ || fail "checkout has uncommitted tracked changes; the staged clone must be a reviewable commit" rm -rf "$WORK_ROOT" mkdir -p "$WORK_ROOT" "$LOG_DIR" "$HOME_SANDBOX/.local/bin" local old_ref="$INSTALL_REF" if [ -z "$old_ref" ] || [ "$old_ref" = "auto" ]; then old_ref="$(git -C "$REPO_ROOT" tag --list 'v[0-9]*' --sort=-creatordate | head -1)" [ -n "$old_ref" ] || fail "no release tags in the checkout to use as OLD" fi local old_sha head_sha old_sha="$(git -C "$REPO_ROOT" rev-parse "${old_ref}^{commit}")" head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD)" [ "$old_sha" != "$head_sha" ] || fail "OLD ($old_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 git -C "$SERVE_REPO" config uploadpack.allowAnySHA1InWant true arm_redirect mkdir -p "$HERMES_HOME" touch "$HERMES_HOME/.skip_upstream_prompt" printf 'OLD_SHA=%s\nOLD_REF=%s\nHEAD_SHA=%s\n' "$old_sha" "$old_ref" "$head_sha" > "$STATE" ok "serve.git main = $old_sha ($old_ref), update target $head_sha" } find_installed_app() { # The bootstrap installs the packaged app; look where the product puts it # (the checkout's release dir), plus /Applications for a copied bundle. local cand for cand in \ "$INSTALL_DIR/apps/desktop/release/mac-arm64/Hermes.app" \ "$INSTALL_DIR/apps/desktop/release/mac/Hermes.app" \ "/Applications/Hermes.app"; do [ -d "$cand" ] && { printf '%s' "$cand"; return 0; } done return 1 } phase_install() { # shellcheck disable=SC1090 . "$STATE" arm_redirect step "installing OLD ($OLD_REF) via the published Hermes-Setup.dmg" local dmg="$WORK_ROOT/Hermes-Setup.dmg" [ -f "$dmg" ] || curl -fsSL -o "$dmg" "$DMG_URL" [ "$(stat -f%z "$dmg")" -gt 1000000 ] || fail "dmg download too small: $(stat -f%z "$dmg") bytes" # curl'd files carry no quarantine attr, but belt and braces on a runner. xattr -dr com.apple.quarantine "$dmg" 2>/dev/null || true local mount mount="$(hdiutil attach -nobrowse -readonly "$dmg" | awk -F'\t' '/\/Volumes\//{print $NF; exit}')" [ -n "$mount" ] || fail "hdiutil attach produced no mount point" ok "dmg mounted at $mount" local app_bin="" local app app="$(find "$mount" -maxdepth 1 -name '*.app' | head -1)" [ -n "$app" ] || { hdiutil detach "$mount" >/dev/null 2>&1 || true; fail "no .app inside the dmg"; } app_bin="$(find "$app/Contents/MacOS" -type f -perm +111 | head -1)" [ -n "$app_bin" ] || fail "no executable inside $app/Contents/MacOS" # The Setup app is Tauri (Rust + system webview): Playwright/Electron # attach never works, and run bare it waits forever on its setup-choice # screen. Launch it in the background with our env (direct exec, not # `open`: launchd inherits NONE of the redirect env) and drive the # "Install Hermes" button with native input. local rc=0 bash "$ASSETS/drive-dmg-install.sh" \ --app-bin "$app_bin" \ --install-dir "$INSTALL_DIR" \ --proof-dir "$LOG_DIR" 2>&1 \ | ts_prefix > "$LOG_DIR/bootstrap-install.log" || rc=$? log_group "Hermes-Setup (dmg bootstrap) transcript" "$LOG_DIR/bootstrap-install.log" hdiutil detach "$mount" >/dev/null 2>&1 || true [ "$rc" -eq 0 ] || fail "dmg bootstrap exited $rc; transcript above" [ -d "$INSTALL_DIR/.git" ] || fail "no checkout landed at $INSTALL_DIR" local got got="$(git -C "$INSTALL_DIR" rev-parse HEAD)" [ "$got" = "$OLD_SHA" ] || fail "installed checkout is $got, expected OLD ($OLD_SHA)" ok "checkout is OLD ($OLD_SHA)" 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-old.log" || fail "hermes --version failed after install" ok "hermes --version works: $(head -c 120 "$LOG_DIR/version-old.log" | tr -d '\n')" find_installed_app >/dev/null || fail "no installed Hermes.app after the dmg bootstrap" ok "installed app: $(find_installed_app)" } ensure_playwright() { # Install the driver's OWN pinned @playwright/test into a scratch dir # (never the installed tree's copy). Idempotent across phases. local pw_dir="$WORK_ROOT/playwright" [ -d "$pw_dir/node_modules/@playwright/test" ] && { printf '%s' "$pw_dir"; return 0; } mkdir -p "$pw_dir" (cd "$pw_dir" && npm install --no-save --no-audit --no-fund \ "@playwright/test@$PLAYWRIGHT_VERSION" 2>&1 | ts_prefix > "$LOG_DIR/playwright-install.log") \ || { log_group "playwright install transcript" "$LOG_DIR/playwright-install.log"; fail "playwright install failed"; } printf '%s' "$pw_dir" } installer_supports() { # $1: ref; $2: flag. Installer flags must match the installer being run, # not this checkout's: older releases reject options added later. # Capture before grepping: a `git show | grep -q` pipe takes SIGPIPE # under pipefail when grep exits at first match, so a supported flag # would read as unsupported. 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). Mirrors the POSIX driver. local script="$WORK_ROOT/install-$2.sh" git -C "$REPO_ROOT" show "$1:scripts/install.sh" > "$script" chmod +x "$script" local flags=(--skip-setup) if installer_supports "$1" "--skip-browser"; then flags+=(--skip-browser) fi if [ "${3:-}" = "desktop" ]; then installer_supports "$1" "--include-desktop" \ || fail "ref $1 does not support --include-desktop; this leg cannot mean what it claims" flags+=(--include-desktop) fi # &1 | ts_prefix > "$LOG_DIR/install-$2.log" || rc=$? log_group "installer ($2) transcript" "$LOG_DIR/install-$2.log" [ "$rc" -eq 0 ] || fail "installer ($2) exited $rc; transcript above" } run_playwright_update() { # $1: spec file to launch from. local spec="$1" local pw_dir pw_dir="$(ensure_playwright)" cp "$ASSETS/launch-from-spec.mjs" "$ASSETS/window-input.cjs" "$pw_dir/" local 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" } phase_update() { # shellcheck disable=SC1090 . "$STATE" arm_redirect 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" # The app must boot configured or the onboarding overlay (a fullscreen # div) eats every click: configure the mock inference server exactly like # the dev:mock flow does, so the app is genuinely configured. # shellcheck source=../install/e2e-assets/mock-provider.sh source "$ASSETS/mock-provider.sh" mock_start "$WORK_ROOT" trap mock_stop EXIT case "$UPDATE_METHOD" in hermes-update) # The CLI route a dmg user takes from a terminal. `--yes` reaches the # update subcommand only in later releases; ask the installed hermes. local hermes="$INSTALL_DIR/venv/bin/hermes" local update_cmd=("$hermes" update) if "$hermes" update --help 2>&1 | grep -qF -- --yes; then update_cmd=("$hermes" update --yes) fi local 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" ;; installer-script) # A dmg user re-running today's install one-liner. run_installer "$HEAD_SHA" head ;; installer-script+desktop) run_installer "$HEAD_SHA" head desktop # The desktop stage is this leg's claim: the rebuilt app must exist. head_app="" for cand in \ "$INSTALL_DIR/apps/desktop/release/mac-arm64/Hermes.app" \ "$INSTALL_DIR/apps/desktop/release/mac/Hermes.app"; do [ -d "$cand" ] && { head_app="$cand"; break; } done [ -n "$head_app" ] || fail "no built Hermes.app under the checkout after the +desktop update" ok "rebuilt app present: $head_app" ;; open-app-update) # The installed app IS the user surface here (double-click the .app); # hand-build the spec Playwright launches from. Env: the redirect set, # which is exactly what the app's children (git, hermes update) need. local app app_bin app="$(find_installed_app)" || fail "no installed app to launch" app_bin="$(find "$app/Contents/MacOS" -type f -perm +111 | head -1)" python3 - "$app_bin" "$WORK_ROOT/launch-spec.json" <<'PYEOF' import json, os, sys spec = { "argv": [sys.argv[1]], "cwd": os.path.dirname(sys.argv[1]), "env": dict(os.environ), "matchedShape": "packaged", } with open(sys.argv[2], "w") as fh: json.dump(spec, fh, indent=2) PYEOF run_playwright_update "$WORK_ROOT/launch-spec.json" ;; hermes-desktop-app-update) # The product's own launch, captured at its spawn site. local hermes="$INSTALL_DIR/venv/bin/hermes" local spec="$WORK_ROOT/launch-spec.json" local 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" [ -f "$spec.captured" ] || fail "hermes desktop exited 0 but no launch was captured" ok "captured $(cat "$spec.captured") launch spec" run_playwright_update "$spec" ;; esac local got got="$(git -C "$INSTALL_DIR" rev-parse HEAD)" [ "$got" = "$HEAD_SHA" ] || fail "checkout is $got, expected HEAD ($HEAD_SHA)" ok "checkout landed on HEAD ($HEAD_SHA)" # Install-side state BEFORE the post-update smoke: on app-update legs the # updater's own transcript is streamed into the app UI and 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 smoke assertion below can `fail` out of the driver, and the # evidence must already be on disk when it does. local ildest="$LOG_DIR/install-logs" mkdir -p "$ildest" cp -R "$HOME_SANDBOX/.hermes/logs" "$ildest/hermes-logs" 2>/dev/null || true local ud="$HOME_SANDBOX/Library/Application Support/Hermes" [ -d "$ud" ] && cp -R "$ud" "$ildest/desktop-userdata" 2>/dev/null || true 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 ls -la "$INSTALL_DIR/venv" > "$ildest/venv-ls.txt" 2>/dev/null || true ok "collected install-side logs to $ildest" "$INSTALL_DIR/venv/bin/hermes" --version 2>&1 | ts_prefix > "$LOG_DIR/version-head.log" \ || fail "hermes --version failed after update" ok "hermes --version works post-update" step "PASS: $OLD_REF -> HEAD via $UPDATE_METHOD" } case "$PHASE" in stage) phase_stage ;; install) phase_install ;; update) phase_update ;; all) phase_stage; phase_install; phase_update ;; *) echo "error: --phase must be stage, install, update or all" >&2; exit 1 ;; esac