423 lines
21 KiB
Bash
423 lines
21 KiB
Bash
#!/usr/bin/env bash
|
|
# Prove the macOS packaged-app update route: a user on a REAL signed OLD
|
|
# bundle, installed in an isolated location, clicks About -> "Update now"
|
|
# and Squirrel.Mac swaps in a REAL signed NEW bundle and relaunches it —
|
|
# with no driver help and no source artifacts anywhere.
|
|
#
|
|
# Sibling of tests/install/macos-desktop-e2e.sh (the dmg/source arm). This
|
|
# arm has NO git redirect, NO staging, NO patching or re-signing: both
|
|
# bundles are the actual release zips resolved and downloaded by the
|
|
# PARENT-owned common resolver (tests/install/e2e-assets/bundle-inputs.mjs),
|
|
# which validates commits, versions, identities (CFBundleIdentifier +
|
|
# teamId), channels and sha256.
|
|
#
|
|
# Phases (state shared via the workroot):
|
|
# install resolve the bundle manifest with the common resolver, verify
|
|
# artifact sha256, unzip the REAL signed OLD zip into an
|
|
# isolated .app location, derive the executable from its
|
|
# Info.plist (never a hard-coded binary name), verify codesign /
|
|
# BOTH identifiers / version / arch / stamp, record the install
|
|
# receipt for the update phase, seed isolated user state
|
|
# update build the loopback static feed from the REAL signed NEW zip in
|
|
# the production update-feed contract on the tag's channel,
|
|
# serve it on an ephemeral port (readiness JSON carries the
|
|
# real port), configure updates.desktop_feed_base_url, start
|
|
# the external relaunch watcher, click the real About ->
|
|
# "Update now" under Playwright, then verify the automatic
|
|
# relaunch (new pid/birth/path), the NEW bundle's codesign/
|
|
# identifiers/version/arch/stamp, backend health, plugin and
|
|
# user-state survival
|
|
#
|
|
# Usage:
|
|
# tests/install/macos-bundled-e2e.sh --phase install|update|all
|
|
# --manifest-url URL --arch arm64|x64
|
|
#
|
|
# CI-only native guard: macOS host + GITHUB_ACTIONS. Nothing here writes to
|
|
# a public feed, dispatches a release, or drives a local GUI outside the
|
|
# runner session.
|
|
|
|
set -euo pipefail
|
|
|
|
PHASE="all"
|
|
MANIFEST_URL=""
|
|
ARCH="arm64"
|
|
UPDATE_WATCH_TIMEOUT_MS=900000
|
|
|
|
while [ "$#" -gt 0 ]; do
|
|
case "$1" in
|
|
--phase)
|
|
[ "$#" -ge 2 ] || { echo 'error: --phase needs a value' >&2; exit 1; }
|
|
PHASE="$2"; shift 2 ;;
|
|
--manifest-url)
|
|
[ "$#" -ge 2 ] || { echo 'error: --manifest-url needs a value' >&2; exit 1; }
|
|
MANIFEST_URL="$2"; shift 2 ;;
|
|
--arch)
|
|
[ "$#" -ge 2 ] || { echo 'error: --arch needs a value' >&2; exit 1; }
|
|
ARCH="$2"; shift 2 ;;
|
|
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
|
|
*) echo "error: unknown argument: $1" >&2; exit 1 ;;
|
|
esac
|
|
done
|
|
case "$ARCH" in arm64|x64) ;; *) echo "error: --arch must be arm64 or x64" >&2; exit 1 ;; esac
|
|
|
|
# Native CI-only guard: this leg runs real signed bundles, real codesign
|
|
# and real Squirrel.Mac, so it is meaningful only on a macOS CI runner.
|
|
[ "$(uname -s)" = "Darwin" ] || { echo "error: this driver runs on macOS only" >&2; exit 1; }
|
|
[ "${GITHUB_ACTIONS:-}" = true ] || { echo "error: this driver is CI-only (GITHUB_ACTIONS required)" >&2; exit 1; }
|
|
|
|
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
|
ASSETS="$REPO_ROOT/tests/install/e2e-assets"
|
|
export TS_BASE=$SECONDS
|
|
NODE_BIN="${HERMES_E2E_NODE:-$(command -v node)}"
|
|
export HERMES_E2E_NODE="$NODE_BIN"
|
|
|
|
# OS activation does not inherit the ordinary journey's sandbox overrides.
|
|
WORK_ROOT="${HERMES_E2E_WORKROOT:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/hermes-bundled-e2e}"
|
|
LOG_DIR="${HERMES_E2E_LOG_DIR:-$WORK_ROOT/logs}"
|
|
HOME_SANDBOX="$WORK_ROOT/home"
|
|
export HOME_SANDBOX
|
|
export HERMES_HOME="$HOME_SANDBOX/.hermes"
|
|
export HOME="$HOME_SANDBOX" # the app must see the ISOLATED home, not the runner's
|
|
export HERMES_DESKTOP_USER_DATA_DIR="$WORK_ROOT/electron-user-data"
|
|
mkdir -p "$WORK_ROOT" "$LOG_DIR" "$HOME_SANDBOX" "$HERMES_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; }
|
|
# shellcheck source=../e2e-assets/preserve-plugins.sh
|
|
source "$(dirname "$0")/e2e-assets/preserve-plugins.sh"
|
|
log_group() {
|
|
printf '::group::%s\n' "$1"
|
|
cat "$2"
|
|
printf '::endgroup::\n'
|
|
}
|
|
|
|
MANIFEST="$WORK_ROOT/bundle-inputs.json"
|
|
INSTALL_RECEIPT="$WORK_ROOT/install-receipt.json"
|
|
FEED_DIR="$WORK_ROOT/feed"
|
|
# Port 0 = ephemeral: the server writes the ACTUAL port to a readiness
|
|
# JSON, and the driver configures the app from that (no fixed 8791).
|
|
FEED_READY="$LOG_DIR/feed-ready.json"
|
|
|
|
export E2E_ARCH="$ARCH"
|
|
|
|
# Background PIDs are GLOBALS so the EXIT trap can kill them from any
|
|
# scope, on success AND on failure paths (no watcher/server leak).
|
|
SERVE_PID=""
|
|
WATCHER_PID=""
|
|
cleanup() {
|
|
if [ -n "$WATCHER_PID" ]; then kill "$WATCHER_PID" 2>/dev/null || true; fi
|
|
if [ -n "$SERVE_PID" ]; then kill "$SERVE_PID" 2>/dev/null || true; fi
|
|
if declare -F mock_stop >/dev/null 2>&1; then mock_stop 2>/dev/null || true; fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
manifest_side() { # $1: old|new, $2: field
|
|
"$NODE_BIN" -e '
|
|
const fs = require("node:fs")
|
|
const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
|
|
const side = m[process.argv[2]]
|
|
console.log(process.argv[3] === "artifact" ? side.artifact.path : side[process.argv[3]])
|
|
' "$MANIFEST" "$1" "$2"
|
|
}
|
|
|
|
require_manifest() {
|
|
[ -f "$MANIFEST" ] || fail "no bundle manifest at $MANIFEST — run the install phase first"
|
|
# Revalidate normalized inputs and downloaded files before every phase.
|
|
"$NODE_BIN" -e '
|
|
const fs = require("node:fs")
|
|
const { validateDownloadedBundle } = require(process.argv[2])
|
|
const m = JSON.parse(fs.readFileSync(process.argv[1], "utf8"))
|
|
validateDownloadedBundle(m, "macos", process.env.E2E_ARCH)
|
|
console.log("bundle manifest valid: " + m.old.tag + " -> " + m.new.tag +
|
|
" (identity " + m.old.identity + ", team " + m.old.teamId + ")")
|
|
' "$MANIFEST" "$ASSETS/bundle-manifest.cjs" 2>&1 | ts_prefix
|
|
}
|
|
|
|
stage_bundle_inputs() {
|
|
[ -n "$MANIFEST_URL" ] || fail "--manifest-url is required for the bundled arm"
|
|
step "resolving the bundle manifest with the common resolver"
|
|
# bundle-inputs.mjs imports semver (declared in the repo root
|
|
# package.json); the checkout on the runner has node_modules via CI setup.
|
|
rm -f "$MANIFEST"
|
|
mkdir -p "$WORK_ROOT"
|
|
"$NODE_BIN" "$ASSETS/bundle-inputs.mjs" \
|
|
--manifest-url "$MANIFEST_URL" --platform macos --arch "$ARCH" \
|
|
--out "$WORK_ROOT" 2>&1 | ts_prefix | tee "$LOG_DIR/bundle-inputs.log"
|
|
ok "bundle inputs staged at $MANIFEST"
|
|
}
|
|
|
|
# Derive the executable from the bundle's own Info.plist — the product
|
|
# name is NOT assumed to be "Hermes" (packaged name: "Hermes Bundled") and
|
|
# nothing is renamed. Returns the absolute binary path.
|
|
derive_app_bin() { # $1: .app path
|
|
local exec_name
|
|
exec_name="$(plutil -extract CFBundleExecutable raw -o - "$1/Contents/Info.plist")"
|
|
[ -n "$exec_name" ] || fail "CFBundleExecutable missing in $1/Contents/Info.plist"
|
|
local bin="$1/Contents/MacOS/$exec_name"
|
|
[ -x "$bin" ] || fail "no executable at $bin (CFBundleExecutable=$exec_name)"
|
|
printf '%s' "$bin"
|
|
}
|
|
|
|
phase_install() {
|
|
stage_bundle_inputs
|
|
require_manifest
|
|
local old_tag old_version old_commit old_identity old_team old_zip
|
|
old_tag="$(manifest_side old tag)"; old_version="$(manifest_side old version)"
|
|
old_commit="$(manifest_side old commit)"; old_identity="$(manifest_side old identity)"
|
|
old_team="$(manifest_side old teamId)"
|
|
old_zip="$(manifest_side old artifact)"
|
|
step "installing OLD bundle $old_tag ($old_version, identity $old_identity, team $old_team)"
|
|
|
|
# Artifact bytes: the common resolver verified the remote sha256; we
|
|
# re-verify the local file so the unzipped bundle provably IS the release.
|
|
local got
|
|
got="$(shasum -a 256 "$old_zip" | awk '{print $1}')"
|
|
local want
|
|
want="$("$NODE_BIN" -e 'const m=require(process.argv[1]); console.log(m.old.artifact.sha256.toLowerCase())' "$MANIFEST")"
|
|
[ "$got" = "$want" ] || fail "OLD zip sha256 $got != manifest $want"
|
|
ok "OLD zip sha256 verified"
|
|
|
|
# Install into an ISOLATED .app location (not /Applications): unzip the
|
|
# real release zip. No patching, no re-signing, no fakes.
|
|
rm -rf "$WORK_ROOT/apps" "$WORK_ROOT/install-old"
|
|
mkdir -p "$WORK_ROOT/install-old" "$WORK_ROOT/apps"
|
|
unzip -q "$old_zip" -d "$WORK_ROOT/install-old"
|
|
local found
|
|
found="$(find "$WORK_ROOT/install-old" -maxdepth 2 -name '*.app' -type d | head -1)"
|
|
[ -n "$found" ] || fail "no .app inside the OLD release zip"
|
|
local old_app="$WORK_ROOT/apps/Hermes.app"
|
|
mv "$found" "$old_app"
|
|
|
|
local old_app_bin
|
|
old_app_bin="$(derive_app_bin "$old_app")"
|
|
ok "OLD bundle installed at $old_app (executable: $(basename "$old_app_bin"))"
|
|
|
|
"$NODE_BIN" "$ASSETS/mac-bundled-verify.mjs" verify-app \
|
|
--app "$old_app" \
|
|
--expect-version "$old_version" \
|
|
--expect-commit "$old_commit" \
|
|
--expect-tag "$old_tag" \
|
|
--expect-identity "$old_identity" \
|
|
--expect-team "$old_team" \
|
|
--expect-arch "$ARCH" \
|
|
--out "$LOG_DIR/old-bundle-verify.json" 2>&1 | ts_prefix | tee "$LOG_DIR/old-bundle-verify.log"
|
|
ok "OLD bundle: codesign, identity, team, version, arch and stamp all match the manifest"
|
|
|
|
# Persist the installed paths for the update phase: the executable name
|
|
# comes from the bundle's own Info.plist and both phases must agree.
|
|
"$NODE_BIN" -e '
|
|
const fs = require("node:fs")
|
|
fs.writeFileSync(process.argv[1], JSON.stringify({
|
|
app: process.argv[2], bin: process.argv[3], arch: process.env.E2E_ARCH,
|
|
recordedAt: new Date().toISOString(),
|
|
}, null, 2) + "\n")
|
|
' "$INSTALL_RECEIPT" "$old_app" "$old_app_bin"
|
|
ok "install receipt persisted at $INSTALL_RECEIPT"
|
|
|
|
# Isolated user state: a private HOME/HERMES_HOME plus one profile marker
|
|
# file the update must leave untouched.
|
|
mkdir -p "$HERMES_HOME"
|
|
printf 'user_state_marker=%s\n' "$old_commit" > "$HERMES_HOME/desktop-bundled-marker.txt"
|
|
ok "isolated user state seeded at $HERMES_HOME"
|
|
}
|
|
|
|
phase_update() {
|
|
require_manifest
|
|
[ -f "$INSTALL_RECEIPT" ] || fail "no install receipt at $INSTALL_RECEIPT — run the install phase first"
|
|
local old_app old_app_bin
|
|
old_app="$("$NODE_BIN" -e 'console.log(require(process.argv[1]).app)' "$INSTALL_RECEIPT")"
|
|
old_app_bin="$("$NODE_BIN" -e 'console.log(require(process.argv[1]).bin)' "$INSTALL_RECEIPT")"
|
|
[ -d "$old_app" ] || fail "installed app $old_app (from the install receipt) is gone"
|
|
[ -x "$old_app_bin" ] || fail "installed executable $old_app_bin (from the install receipt) is gone"
|
|
ok "update phase using the receipt's installed executable: $old_app_bin"
|
|
|
|
local new_tag new_version new_commit new_identity new_team new_zip
|
|
new_tag="$(manifest_side new tag)"; new_version="$(manifest_side new version)"
|
|
new_commit="$(manifest_side new commit)"; new_identity="$(manifest_side new identity)"
|
|
new_team="$(manifest_side new teamId)"
|
|
new_zip="$(manifest_side new artifact)"
|
|
|
|
# Snapshot + seed plugin fixtures BEFORE anything moves (shared owner).
|
|
preserve_before_upgrade
|
|
|
|
# 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 (shared owner: mock-provider.sh). It writes
|
|
# $HERMES_HOME/config.yaml; the feed base URL below is appended after.
|
|
# shellcheck source=../e2e-assets/mock-provider.sh
|
|
source "$ASSETS/mock-provider.sh"
|
|
PATH="$(dirname "$HERMES_E2E_NODE"):$PATH" mock_start "$WORK_ROOT"
|
|
|
|
# ── the controlled loopback feed ──────────────────────────────────────
|
|
step "building the loopback feed from the REAL NEW signed zip"
|
|
local got want
|
|
got="$(shasum -a 256 "$new_zip" | awk '{print $1}')"
|
|
want="$("$NODE_BIN" -e 'const m=require(process.argv[1]); console.log(m.new.artifact.sha256.toLowerCase())' "$MANIFEST")"
|
|
[ "$got" = "$want" ] || fail "NEW zip sha256 $got != manifest $want"
|
|
rm -rf "$FEED_DIR"
|
|
"$NODE_BIN" "$ASSETS/mac-bundled-feed.mjs" \
|
|
--out "$FEED_DIR" --zip "$new_zip" \
|
|
--version "$new_version" --tag "$new_tag" --arch "$ARCH" \
|
|
2>&1 | ts_prefix | tee "$LOG_DIR/feed-build.json"
|
|
ok "feed materialized at $FEED_DIR"
|
|
|
|
"$NODE_BIN" "$ASSETS/mac-bundled-serve.mjs" --dir "$FEED_DIR" --port 0 \
|
|
--log "$LOG_DIR/feed-requests.jsonl" --ready "$FEED_READY" \
|
|
> "$LOG_DIR/feed-server.log" 2>&1 &
|
|
SERVE_PID=$!
|
|
for _ in $(seq 1 50); do
|
|
[ -f "$FEED_READY" ] && break
|
|
kill -0 "$SERVE_PID" 2>/dev/null || { cat "$LOG_DIR/feed-server.log" | ts_prefix; fail "feed server exited before listening"; }
|
|
sleep 0.2
|
|
done
|
|
[ -f "$FEED_READY" ] || fail "feed server never reported its port ($FEED_READY)"
|
|
local feed_port feed_url
|
|
feed_port="$("$NODE_BIN" -e 'console.log(require(process.argv[1]).port)' "$FEED_READY")"
|
|
feed_url="http://127.0.0.1:$feed_port"
|
|
curl -fsS "$feed_url/__healthz" >/dev/null 2>&1 || fail "loopback feed did not answer on $feed_url"
|
|
ok "loopback feed serving on $feed_url (ephemeral port $feed_port)"
|
|
|
|
# ── configure the app like a user: config.yaml ────────────────────────
|
|
# The production resolution order is config updates.desktop_feed_base_url
|
|
# first (main.ts resolveDesktopFeedBaseUrl). Loopback HTTP is the one
|
|
# non-HTTPS override the production client accepts (mac-client.ts).
|
|
step "configuring updates.desktop_feed_base_url in the isolated config"
|
|
local config_feed_line=" desktop_feed_base_url: $feed_url"
|
|
grep -q '^updates:' "$HERMES_HOME/config.yaml" 2>/dev/null || printf '\nupdates:\n' >> "$HERMES_HOME/config.yaml"
|
|
grep -qF "$config_feed_line" "$HERMES_HOME/config.yaml" || printf '%s\n' "$config_feed_line" >> "$HERMES_HOME/config.yaml"
|
|
cat "$HERMES_HOME/config.yaml" | ts_prefix | tee "$LOG_DIR/config.yaml"
|
|
ok "feed base URL configured: $feed_url"
|
|
|
|
# ── the external relaunch watcher ─────────────────────────────────────
|
|
# Started by THIS shell, detached from Playwright and from the app. It
|
|
# owns the automatic-relaunch proof end to end.
|
|
step "starting the external relaunch watcher"
|
|
"$NODE_BIN" "$ASSETS/mac-bundled-relaunch-watch.cjs" \
|
|
--app-bin "$old_app_bin" \
|
|
--out "$LOG_DIR/relaunch-proof.json" \
|
|
--old-pid-file "$LOG_DIR/old-pid" \
|
|
--new-pid-file "$LOG_DIR/new-pid" \
|
|
--timeout-ms "$UPDATE_WATCH_TIMEOUT_MS" > "$LOG_DIR/relaunch-watch.log" 2>&1 &
|
|
WATCHER_PID=$!
|
|
ok "watcher running (pid $WATCHER_PID)"
|
|
|
|
# ── the real user trigger ─────────────────────────────────────────────
|
|
step "launching the OLD app and clicking About -> Update now"
|
|
local rc=0
|
|
(cd "$WORK_ROOT" && "$NODE_BIN" "$ASSETS/mac-bundled-update-driver.mjs" \
|
|
--app-bin "$old_app_bin" \
|
|
--old-sha "$(manifest_side old commit)" --chat-out "$LOG_DIR" --mock-url "$HERMES_E2E_MOCK_URL" \
|
|
--shots "$LOG_DIR/shots" \
|
|
--close-timeout-ms 420000 2>&1 | ts_prefix | tee "$LOG_DIR/app-update.log") || rc=$?
|
|
log_group "in-app update (Playwright) transcript" "$LOG_DIR/app-update.log"
|
|
[ "$rc" -eq 0 ] || fail "in-app update driver exited $rc; transcript above"
|
|
|
|
step "waiting for Squirrel.Mac to swap and relaunch (watcher owns the proof)"
|
|
if ! wait "$WATCHER_PID"; then
|
|
cat "$LOG_DIR/relaunch-proof.json" 2>/dev/null | ts_prefix || true
|
|
fail "relaunch watcher did not observe the automatic relaunch within ${UPDATE_WATCH_TIMEOUT_MS}ms"
|
|
fi
|
|
WATCHER_PID=""
|
|
log_group "relaunch watcher record" "$LOG_DIR/relaunch-proof.json"
|
|
|
|
local old_pid new_pid
|
|
old_pid="$(cat "$LOG_DIR/old-pid")"
|
|
new_pid="$(cat "$LOG_DIR/new-pid")"
|
|
[ "$old_pid" != "$new_pid" ] || fail "relaunch proof shows the SAME pid ($new_pid); no replacement happened"
|
|
"$NODE_BIN" -e '
|
|
const r = require(process.argv[1])
|
|
if (r.newBirth && r.oldBirth && r.newBirth === r.oldBirth) process.exit(1)
|
|
' "$LOG_DIR/relaunch-proof.json" || fail "old and new processes report the same birth time"
|
|
# The relaunch must be at the SAME installed path: Squirrel replaces the
|
|
# bundle in place; a different path would mean something else started it.
|
|
"$NODE_BIN" -e '
|
|
const r = require(process.argv[1])
|
|
if (!r.oldPid || !r.newPid || !r.oldExitedAt || !r.newSeenAt || r.appBin !== process.argv[2]) process.exit(1)
|
|
' "$LOG_DIR/relaunch-proof.json" "$old_app_bin" || fail "incomplete relaunch proof record"
|
|
ok "automatic relaunch: old pid $old_pid exited, new pid $new_pid at $old_app_bin (no driver launch)"
|
|
|
|
# ── the NEW bundle in place ───────────────────────────────────────────
|
|
step "verifying the NEW bundle Squirrel installed"
|
|
"$NODE_BIN" "$ASSETS/mac-bundled-verify.mjs" verify-app \
|
|
--app "$old_app" \
|
|
--expect-version "$new_version" \
|
|
--expect-commit "$new_commit" \
|
|
--expect-tag "$new_tag" \
|
|
--expect-identity "$new_identity" \
|
|
--expect-team "$new_team" \
|
|
--expect-arch "$ARCH" \
|
|
--out "$LOG_DIR/new-bundle-verify.json" 2>&1 | ts_prefix | tee "$LOG_DIR/new-bundle-verify.log"
|
|
ok "NEW bundle: codesign, identity ($new_identity), team ($new_team), version $new_version, stamp commit $new_commit"
|
|
|
|
# ── old processes are gone, new backend healthy ───────────────────────
|
|
if pgrep -f "$old_app_bin" | grep -qvw "$new_pid"; then
|
|
fail "old bundle processes still running after the update"
|
|
fi
|
|
ok "no stale bundle processes"
|
|
|
|
step "probing the relaunched app's backend health"
|
|
local backend_pid backend_port health
|
|
backend_pid="$(ps -axo pid=,ppid=,command= | awk -v p="$new_pid" '$2==p && /serve/ && /hermes/ {print $1; exit}')"
|
|
if [ -n "$backend_pid" ]; then
|
|
backend_port="$(lsof -nP -a -p "$backend_pid" -iTCP -sTCP:LISTEN | awk 'NR>1{sub(".*:","",$9); print $9; exit}')"
|
|
fi
|
|
if [ -z "${backend_port:-}" ]; then
|
|
# Fallback: the newest listening 127.0.0.1 port owned by any descendant
|
|
# of the relaunched app (helper wrappers can reparent the backend).
|
|
backend_port="$(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '/127\.0\.0\.1/ {print $1, $2, $9}' \
|
|
| while read -r _ pid addr; do
|
|
if ps -o ppid= -p "$pid" 2>/dev/null | grep -qw "$new_pid" || [ "$pid" = "$new_pid" ]; then
|
|
printf '%s\n' "${addr##*:}"; break
|
|
fi
|
|
done)"
|
|
fi
|
|
[ -n "$backend_port" ] || fail "could not discover the relaunched backend's listening port"
|
|
for _ in $(seq 1 30); do
|
|
health="$(curl -fsS "http://127.0.0.1:$backend_port/api/health" 2>/dev/null)" && break
|
|
sleep 2
|
|
done
|
|
[ -n "${health:-}" ] || fail "backend /api/health never answered on 127.0.0.1:$backend_port"
|
|
printf '%s\n' "$health" | ts_prefix | tee "$LOG_DIR/backend-health.json"
|
|
ok "backend healthy on 127.0.0.1:$backend_port"
|
|
|
|
# ── user state survived ───────────────────────────────────────────────
|
|
preserve_after_upgrade
|
|
grep -qF "user_state_marker=$(manifest_side old commit)" "$HERMES_HOME/desktop-bundled-marker.txt" \
|
|
|| fail "isolated user-state marker did not survive the update"
|
|
ok "isolated user state survived"
|
|
|
|
# Only now may the driver reopen NEW; automatic relaunch already passed.
|
|
step "post-update-launch chat: gracefully quit verified NEW, then reopen the same binary"
|
|
# NSRunningApplication sends a normal Quit to this PID without accessibility
|
|
# permission or a LaunchServices lookup that could itself start another app.
|
|
osascript -l JavaScript -e 'ObjC.import("AppKit"); function run(args) {
|
|
const app = $.NSRunningApplication.runningApplicationWithProcessIdentifier(Number(args[0]));
|
|
if (!app || !app.terminate) throw new Error("normal Quit refused");
|
|
}' "$new_pid" || fail "could not request normal Quit of the automatically relaunched NEW app"
|
|
for _ in $(seq 1 60); do
|
|
kill -0 "$new_pid" 2>/dev/null || break
|
|
sleep 0.5
|
|
done
|
|
if kill -0 "$new_pid" 2>/dev/null; then
|
|
fail "NEW did not quit normally; no force-kill or smoke relaunch attempted"
|
|
fi
|
|
printf '{"phase":"new","launch":"post-update-launch","automaticRelaunchProof":"relaunch-proof.json"}\n' > "$LOG_DIR/desktop-chat-new-launch.json"
|
|
"$NODE_BIN" "$ASSETS/desktop-smoke.ts" --exe "$old_app_bin" \
|
|
--root "$old_app/Contents/Resources/agent-payload" --origin bundled \
|
|
--home "$HERMES_HOME" --user-data "$HERMES_DESKTOP_USER_DATA_DIR" \
|
|
--out "$LOG_DIR" --phase new --expect-commit "$new_commit" --mock-url "$HERMES_E2E_MOCK_URL" \
|
|
2>&1 | ts_prefix | tee "$LOG_DIR/desktop-chat-new.log"
|
|
|
|
step "PASS: packaged $(manifest_side old tag) -> $new_tag via the real About -> Update now route"
|
|
}
|
|
|
|
case "$PHASE" in
|
|
install) phase_install ;;
|
|
update) phase_update ;;
|
|
all) phase_install; phase_update ;;
|
|
*) echo "error: --phase must be install, update or all" >&2; exit 1 ;;
|
|
esac
|