diff --git a/scripts/e2e/lib/parallels-macos-common.sh b/scripts/e2e/lib/parallels-macos-common.sh new file mode 100644 index 00000000000..29854085418 --- /dev/null +++ b/scripts/e2e/lib/parallels-macos-common.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +parallels_macos_resolve_desktop_user() { + local vm_name="$1" + local user + user="$(prlctl exec "$vm_name" /usr/bin/stat -f '%Su' /dev/console 2>/dev/null | tr -d '\r' | tail -n 1 || true)" + if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then + printf '%s\n' "$user" + return 0 + fi + prlctl exec "$vm_name" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \ + | tr -d '\r' \ + | awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }' +} + +parallels_macos_resolve_desktop_home() { + local vm_name="$1" + local user="$2" + local home + home="$( + prlctl exec "$vm_name" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \ + | tr -d '\r' \ + | awk '/NFSHomeDirectory:/ { print $2; exit }' + )" + if [[ -n "$home" ]]; then + printf '%s\n' "$home" + else + printf '/Users/%s\n' "$user" + fi +} + +parallels_macos_current_user_available() { + local vm_name="$1" + prlctl exec "$vm_name" --current-user /usr/bin/whoami >/dev/null 2>&1 +} + +parallels_macos_desktop_user_exec() { + local vm_name="$1" + local api_key_env="$2" + local api_key_value="$3" + shift 3 + + if parallels_macos_current_user_available "$vm_name"; then + prlctl exec "$vm_name" --current-user /usr/bin/env "$api_key_env=$api_key_value" "$@" + return + fi + + local user home + user="$(parallels_macos_resolve_desktop_user "$vm_name")" + [[ -n "$user" ]] || { + printf 'unable to resolve macOS desktop user for sudo fallback\n' >&2 + return 1 + } + home="$(parallels_macos_resolve_desktop_home "$vm_name" "$user")" + printf 'warn: macOS --current-user unavailable; using root sudo fallback for %s\n' "$user" >&2 + prlctl exec "$vm_name" /usr/bin/sudo -u "$user" /usr/bin/env \ + "HOME=$home" \ + "USER=$user" \ + "LOGNAME=$user" \ + "PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$api_key_env=$api_key_value" \ + "$@" +} diff --git a/scripts/e2e/parallels-macos-smoke.sh b/scripts/e2e/parallels-macos-smoke.sh index 93e40b7c857..703955205f7 100644 --- a/scripts/e2e/parallels-macos-smoke.sh +++ b/scripts/e2e/parallels-macos-smoke.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +source "$ROOT_DIR/scripts/e2e/lib/parallels-macos-common.sh" VM_NAME="macOS Tahoe" SNAPSHOT_HINT="macOS 26.3.1 latest" @@ -38,11 +39,14 @@ GUEST_CURRENT_USER_TRANSPORT="prlctl" MAIN_TGZ_DIR="$(mktemp -d)" MAIN_TGZ_PATH="" PACKED_MAIN_COMMIT_SHORT="" +TARGET_EXPECT_VERSION="" SERVER_PID="" RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-smoke.XXXXXX)" BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock" -TIMEOUT_INSTALL_S=900 +TIMEOUT_INSTALL_SITE_S=900 +TIMEOUT_INSTALL_TGZ_S=900 +TIMEOUT_INSTALL_REGISTRY_S=480 TIMEOUT_UPDATE_DEV_S=1500 TIMEOUT_VERIFY_S=60 TIMEOUT_ONBOARD_S=180 @@ -71,6 +75,10 @@ say() { } artifact_label() { + if target_package_installs_directly; then + printf 'target package spec' + return + fi if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then printf 'target package tgz' return @@ -78,6 +86,16 @@ artifact_label() { printf 'current main tgz' } +target_package_installs_directly() { + [[ -n "$TARGET_PACKAGE_SPEC" ]] || return 1 + case "$TARGET_PACKAGE_SPEC" in + http://*|https://*|file:*|/*|./*|../*|*.tgz) + return 1 + ;; + esac + return 0 +} + warn() { printf 'warn: %s\n' "$*" >&2 } @@ -116,9 +134,9 @@ Options: --snapshot-hint Snapshot name substring/fuzzy match. Default: "macOS 26.3.1 latest" --mode - fresh = fresh snapshot -> target package/current main tgz -> onboard smoke + fresh = fresh snapshot -> target package/current main install artifact -> onboard smoke upgrade = fresh snapshot -> pinned latest stable -> dev channel update -> onboard smoke - (or latest stable -> target package tgz when --target-package-spec is set) + (or latest stable -> target package install when --target-package-spec is set) both = run both lanes --provider Provider auth/model lane. Default: openai @@ -273,12 +291,25 @@ discord_smoke_enabled() { [[ -n "$DISCORD_TOKEN_VALUE" && -n "$DISCORD_GUILD_ID" && -n "$DISCORD_CHANNEL_ID" ]] } +fresh_uses_host_tgz() { + if [[ -z "$TARGET_PACKAGE_SPEC" ]]; then + return 0 + fi + ! target_package_installs_directly +} + upgrade_uses_host_tgz() { - [[ -n "$TARGET_PACKAGE_SPEC" ]] + [[ -n "$TARGET_PACKAGE_SPEC" ]] && ! target_package_installs_directly } needs_host_tgz() { - [[ "$MODE" == "fresh" || "$MODE" == "both" ]] || upgrade_uses_host_tgz + if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then + fresh_uses_host_tgz && return 0 + fi + if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then + upgrade_uses_host_tgz && return 0 + fi + return 1 } upgrade_summary_label() { @@ -484,8 +515,7 @@ wait_for_vm_status() { } resolve_headless_guest_user() { - prlctl exec "$VM_NAME" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \ - | awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }' + parallels_macos_resolve_desktop_user "$VM_NAME" } guest_current_user_transport_path() { @@ -606,8 +636,10 @@ guest_current_user_exec_path() { local path_value="$1" shift if headless_guest_fallback; then + local guest_home + guest_home="$(parallels_macos_resolve_desktop_home "$VM_NAME" "$GUEST_CURRENT_USER")" prlctl exec "$VM_NAME" /usr/bin/sudo -H -u "$GUEST_CURRENT_USER" /usr/bin/env \ - "HOME=/Users/$GUEST_CURRENT_USER" \ + "HOME=$guest_home" \ "USER=$GUEST_CURRENT_USER" \ "LOGNAME=$GUEST_CURRENT_USER" \ "PATH=$path_value" \ @@ -630,7 +662,7 @@ guest_current_user_node_cli() { resolve_guest_current_user_home() { local user_name user_name="$(guest_current_user_exec /usr/bin/id -un | tr -d '\r')" - printf '/Users/%s\n' "$user_name" + parallels_macos_resolve_desktop_home "$VM_NAME" "$user_name" } resolve_guest_git_openclaw_entry() { @@ -714,9 +746,11 @@ guest_current_user_sh() { script+="$1" if headless_guest_fallback; then script_path="/tmp/openclaw-prl-${BASHPID:-$$}-$RANDOM.sh" + local guest_home + guest_home="$(parallels_macos_resolve_desktop_home "$VM_NAME" "$GUEST_CURRENT_USER")" printf '%s' "$script" | /usr/bin/base64 | prlctl exec "$VM_NAME" \ /usr/bin/sudo -H -u "$GUEST_CURRENT_USER" /usr/bin/env \ - "HOME=/Users/$GUEST_CURRENT_USER" \ + "HOME=$guest_home" \ "USER=$GUEST_CURRENT_USER" \ "LOGNAME=$GUEST_CURRENT_USER" \ /usr/bin/base64 -D -o "$script_path" @@ -736,6 +770,83 @@ guest_current_user_tail_file() { guest_current_user_exec /usr/bin/tail -n "$lines" "$file_path" } +latest_guest_npm_debug_log_path() { + local guest_home="$1" + guest_current_user_sh "$(cat </dev/null)" + rc=$? + set -e + [[ $rc -eq 0 ]] || return 0 + CONTENT="$content" PREFIX="$prefix" python3 - "$state_path" <<'PY' +import os +import pathlib +import sys + +state_path = pathlib.Path(sys.argv[1]) +previous = state_path.read_text(encoding="utf-8", errors="replace") if state_path.exists() else "" +current = os.environ["CONTENT"].replace("\r\n", "\n").replace("\r", "\n") +prefix = os.environ["PREFIX"] + +if current.startswith(previous): + delta = current[len(previous):] +else: + delta = current + +if delta: + for line in delta.splitlines(): + print(f"{prefix}{line}") + +state_path.write_text(current, encoding="utf-8") +PY +} + run_logged_guest_current_user_sh() { local script="$1" local log_path="$2" @@ -743,23 +854,22 @@ run_logged_guest_current_user_sh() { local timeout_s="$4" local runner_path="$5" local deadline rc runner_body write_runner_cmd - if headless_guest_fallback; then - set +e - guest_current_user_sh "$script" >"$log_path" 2>&1 - rc=$? - set -e - return "$rc" - fi + local guest_home guest_log_state_path latest_npm_log_path latest_npm_log_state_path npm_state_path guest_current_user_exec /bin/rm -f "$log_path" "$done_path" "$runner_path" runner_body="$(cat < "$done_path"; exit "\$status"' EXIT -umask 022 -export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}" -if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi -cd "\$HOME" -$script +status=0 +( + set -eu + set -o pipefail + umask 022 + export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin:\${PATH:-}" + if [ -z "\${HOME:-}" ]; then export HOME="/Users/\$(id -un)"; fi + cd "\$HOME" + $script +) || status=\$? +printf '__OPENCLAW_RC__:%s\n' "\$status" +printf '%s\n' "\$status" > "$done_path" +exit "\$status" EOF )" write_runner_cmd="/bin/rm -f $(shell_quote "$runner_path")"$'\n' @@ -767,18 +877,41 @@ EOF write_runner_cmd+="$runner_body"$'\n' write_runner_cmd+="__OPENCLAW_RUNNER__"$'\n' write_runner_cmd+="/bin/chmod +x $(shell_quote "$runner_path")"$'\n' - write_runner_cmd+="nohup /bin/bash $(shell_quote "$runner_path") > $(shell_quote "$log_path") 2>&1 < /dev/null &" + write_runner_cmd+="(/bin/bash $(shell_quote "$runner_path") > $(shell_quote "$log_path") 2>&1 < /dev/null &) >/dev/null 2>&1" guest_current_user_sh "$write_runner_cmd" + guest_home="$(resolve_guest_current_user_home)" + guest_log_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-log-state.XXXXXX")" + latest_npm_log_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-npm-log-state.XXXXXX")" + npm_state_path="$(mktemp "${TMPDIR:-/tmp}/openclaw-guest-npm-log-path.XXXXXX")" + : >"$guest_log_state_path" + : >"$latest_npm_log_state_path" + : >"$npm_state_path" deadline=$((SECONDS + timeout_s)) while (( SECONDS < deadline )); do - if guest_current_user_exec /bin/test -f "$done_path" >/dev/null 2>&1; then - rc="$(guest_current_user_exec /bin/cat "$done_path" | tr -d '\r\n')" + stream_guest_file_delta "$log_path" "$guest_log_state_path" "" + latest_npm_log_path="$(latest_guest_npm_debug_log_path "$guest_home" || true)" + if [[ -n "$latest_npm_log_path" ]]; then + if [[ "$(cat "$npm_state_path" 2>/dev/null || true)" != "$latest_npm_log_path" ]]; then + printf '%s\n' "$latest_npm_log_path" >"$npm_state_path" + : >"$latest_npm_log_state_path" + printf 'npm-debug: %s\n' "$latest_npm_log_path" + fi + stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " + fi + rc="$(guest_runner_rc_from_log "$log_path" 2>/dev/null || true)" + if [[ "$rc" =~ ^-?[0-9]+$ ]]; then guest_current_user_exec /bin/rm -f "$done_path" "$runner_path" >/dev/null 2>&1 || true + stream_guest_file_delta "$log_path" "$guest_log_state_path" "" + if [[ -n "$latest_npm_log_path" ]]; then + stream_guest_file_delta "$latest_npm_log_path" "$latest_npm_log_state_path" "npm-debug: " + fi + rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" [[ -n "$rc" ]] || rc=1 return "$rc" fi sleep 2 done + rm -f "$guest_log_state_path" "$latest_npm_log_state_path" "$npm_state_path" warn "guest script timed out after ${timeout_s}s" guest_current_user_tail_file "$log_path" 120 >&2 || true return 124 @@ -937,6 +1070,12 @@ extract_package_build_commit_from_tgz() { pack_main_tgz() { local short_head pkg packed_commit + if target_package_installs_directly; then + say "Use direct guest install for target package spec: $TARGET_PACKAGE_SPEC" + TARGET_EXPECT_VERSION="$(npm view "$TARGET_PACKAGE_SPEC" version --userconfig "$(mktemp)")" + say "Target package version: $TARGET_EXPECT_VERSION" + return + fi if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then say "Pack target package tgz: $TARGET_PACKAGE_SPEC" pkg="$( @@ -1049,17 +1188,38 @@ start_server() { kill -0 "$SERVER_PID" >/dev/null 2>&1 || die "failed to start host HTTP server" } +install_main_timeout() { + if target_package_installs_directly; then + printf '%s\n' "$TIMEOUT_INSTALL_REGISTRY_S" + return + fi + printf '%s\n' "$TIMEOUT_INSTALL_TGZ_S" +} + install_main_tgz() { local host_ip="$1" local temp_name="$2" - local tgz_url_q + local install_done install_log install_runner tgz_url_q + install_log="/tmp/${temp_name}.log" + install_done="/tmp/${temp_name}.done" + install_runner="/tmp/${temp_name}.runner.sh" + if target_package_installs_directly; then + run_logged_guest_current_user_sh "$(cat </dev/null | tr -d '\r' | tail -n 1 || true)" - if [[ "$user" =~ ^[A-Za-z0-9._-]+$ && "$user" != "root" && "$user" != "loginwindow" ]]; then - printf '%s\n' "$user" - return 0 - fi - prlctl exec "$MACOS_VM" /usr/bin/dscl . -list /Users NFSHomeDirectory 2>/dev/null \ - | tr -d '\r' \ - | awk '$2 ~ /^\/Users\// && $1 !~ /^_/ && $1 != "Shared" && $1 != ".localized" { print $1; exit }' -} - -resolve_macos_desktop_home() { - local user="$1" - local home - home="$( - prlctl exec "$MACOS_VM" /usr/bin/dscl . -read "/Users/$user" NFSHomeDirectory 2>/dev/null \ - | tr -d '\r' \ - | awk '/NFSHomeDirectory:/ { print $2; exit }' - )" - if [[ -n "$home" ]]; then - printf '%s\n' "$home" - else - printf '/Users/%s\n' "$user" - fi -} - -macos_current_user_available() { - prlctl exec "$MACOS_VM" --current-user /usr/bin/whoami >/dev/null 2>&1 -} - macos_desktop_user_exec() { - if macos_current_user_available; then - prlctl exec "$MACOS_VM" --current-user /usr/bin/env "$API_KEY_ENV=$API_KEY_VALUE" "$@" - return - fi - - local user home - user="$(resolve_macos_desktop_user)" - [[ -n "$user" ]] || die "unable to resolve macOS desktop user for sudo fallback" - home="$(resolve_macos_desktop_home "$user")" - warn "macOS --current-user unavailable; using root sudo fallback for $user" - prlctl exec "$MACOS_VM" /usr/bin/sudo -u "$user" /usr/bin/env \ - "HOME=$home" \ - "USER=$user" \ - "LOGNAME=$user" \ - "PATH=/opt/homebrew/bin:/opt/homebrew/opt/node/bin:/opt/homebrew/sbin:/usr/bin:/bin:/usr/sbin:/sbin" \ - "$API_KEY_ENV=$API_KEY_VALUE" \ - "$@" + parallels_macos_desktop_user_exec "$MACOS_VM" "$API_KEY_ENV" "$API_KEY_VALUE" "$@" } guest_powershell_poll() {