mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-25 13:10:03 +00:00
Bind unattended installer updates to exact menu targets
Use the reviewed server-only selector in the existing-installation menu and bind automatic selection to available channel rows. A current selected release is a no-op; unavailable metadata or an automatic downgrade stops before mutations. Preview selection retains its channel, numeric beta/RC revisions compare correctly, and deliberate reinstall pins the actual current version. Execute the actual menu through confined offline transport and stopped mutation boundaries. Preserve explicit version, interactive removal, signature and all-unit quiescence contracts; reconcile the inherited comment-only preview assertion with the real selector consumer. Change-source: pulse-maintainer
This commit is contained in:
@@ -15,6 +15,37 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
### Existing-installation menu actions require exact target and intent
|
||||
|
||||
The root server installer's existing-installation menu admits stable/preview
|
||||
targets through the same complete, published server-only release selector as its
|
||||
installer and archive downloads. Chart tags, drafts, malformed metadata and
|
||||
failed transport output must not become offered versions. Explicit version pins
|
||||
remain a separate deliberate route; signature and archive admission are unchanged.
|
||||
|
||||
Without interactive input (including Docker), only a newer release in the
|
||||
requested/configured channel may select an update. Selection uses the actual
|
||||
available row, never an assumed option number: absent or current versions can
|
||||
otherwise move preview, reinstall or removal into that number. A current selected
|
||||
version is an unchanged no-op; unavailable metadata or an automatic downgrade
|
||||
returns nonzero before readiness, backup, auto-update consent or any mutation.
|
||||
Preview following the newest stable preserves the preview channel preference.
|
||||
Beta/RC revision ordering is numeric within the same stage, so `.10` upgrades
|
||||
`.9` and the reverse remains an automatic rollback refusal.
|
||||
|
||||
Interactive update, removal and cancellation retain their displayed meanings.
|
||||
Explicit reinstall pins the known current server version rather than discovering
|
||||
and installing latest; an unknown current version requires a deliberate exact
|
||||
pin instead. Removal still requires the independent all-unit quiescence checks.
|
||||
|
||||
`TestRootInstallExistingMenuRequiresExactAutomaticIntent` executes eighteen
|
||||
ordinary-user controls against actual sourced installer `main`, with confined
|
||||
release transport and stopped mutation boundaries. They cover disappearing rows,
|
||||
both automatic entry paths, channel intent, chart/draft/metadata refusal, automatic
|
||||
rollback refusal, exact reinstall and deliberate removal/cancel. The same controls
|
||||
fail the prior menu; they do not establish native systemd, signed installation,
|
||||
published availability or customer recovery.
|
||||
|
||||
### Windows replacement and removal refuse uncertain service state
|
||||
|
||||
Before replacing an agent binary or changing its token/connection state, and
|
||||
|
||||
+71
-20
@@ -2797,6 +2797,17 @@ compare_versions() {
|
||||
elif [[ -n "$suffix_v1" ]] && [[ -z "$suffix_v2" ]]; then
|
||||
return 2 # v1 (rc) < v2 (stable)
|
||||
elif [[ -n "$suffix_v1" ]] && [[ -n "$suffix_v2" ]]; then
|
||||
# Published preview revisions are numeric: rc.10 follows rc.9, not
|
||||
# vice versa. Keep the existing lexical fallback for other suffixes.
|
||||
if [[ "$suffix_v1" =~ ^(beta|rc)[.][0-9]+$ && "$suffix_v2" =~ ^(beta|rc)[.][0-9]+$ && "${suffix_v1%.*}" == "${suffix_v2%.*}" ]]; then
|
||||
local revision_v1="${suffix_v1##*.}" revision_v2="${suffix_v2##*.}"
|
||||
if (( 10#$revision_v1 > 10#$revision_v2 )); then
|
||||
return 1
|
||||
elif (( 10#$revision_v1 < 10#$revision_v2 )); then
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
# Both have suffixes, compare them lexicographically
|
||||
if [[ "$suffix_v1" > "$suffix_v2" ]]; then
|
||||
return 1
|
||||
@@ -5079,19 +5090,13 @@ main() {
|
||||
return 0
|
||||
fi
|
||||
|
||||
# Get both stable and RC versions
|
||||
# Try GitHub API first, but have a fallback - with timeout protection
|
||||
# Use the same complete, server-only metadata admission as downloads.
|
||||
# A chart tag or a draft must never become an existing-install action.
|
||||
local STABLE_VERSION=""
|
||||
STABLE_VERSION=$(resolve_latest_release_tag_for_channel stable 2>/dev/null || true)
|
||||
|
||||
# For RC, we need the API, so if it fails just use empty
|
||||
local RC_VERSION=""
|
||||
if command -v timeout >/dev/null 2>&1; then
|
||||
RC_VERSION=$(timeout 10 curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
|
||||
else
|
||||
RC_VERSION=$(curl -s --connect-timeout 5 --max-time 10 https://api.github.com/repos/$GITHUB_REPO/releases 2>/dev/null | grep '"tag_name":' | head -1 | sed -E 's/.*"([^"]+)".*/\1/' || true)
|
||||
fi
|
||||
|
||||
RC_VERSION=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)
|
||||
|
||||
# Determine default update channel
|
||||
UPDATE_CHANNEL="stable"
|
||||
|
||||
@@ -5110,12 +5115,15 @@ main() {
|
||||
|
||||
# Show update options based on available versions
|
||||
local menu_option=1
|
||||
local stable_choice="" rc_choice=""
|
||||
if [[ -n "$STABLE_VERSION" ]] && [[ "$STABLE_VERSION" != "$CURRENT_VERSION" ]]; then
|
||||
stable_choice=$menu_option
|
||||
echo "${menu_option}) Update to $STABLE_VERSION (stable)"
|
||||
((menu_option++))
|
||||
fi
|
||||
|
||||
if [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]] && [[ "$RC_VERSION" != "$CURRENT_VERSION" ]]; then
|
||||
rc_choice=$menu_option
|
||||
echo "${menu_option}) Update to $RC_VERSION (prerelease preview)"
|
||||
((menu_option++))
|
||||
fi
|
||||
@@ -5129,27 +5137,59 @@ main() {
|
||||
|
||||
# Try to read user choice interactively
|
||||
# safe_read handles both normal and piped input (via /dev/tty)
|
||||
local automatic_choice=false
|
||||
local requested_channel="$UPDATE_CHANNEL"
|
||||
if [[ "$IN_DOCKER" == "true" ]]; then
|
||||
# In Docker, always auto-select
|
||||
print_info "Docker environment detected. Auto-selecting update option."
|
||||
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
|
||||
choice=2 # RC version
|
||||
else
|
||||
choice=1 # Stable version
|
||||
fi
|
||||
automatic_choice=true
|
||||
elif safe_read "Select option [1-${max_option}]: " choice; then
|
||||
# Successfully read user choice (either from stdin or /dev/tty)
|
||||
: # Do nothing, choice was set
|
||||
else
|
||||
# safe_read failed - truly non-interactive
|
||||
print_info "Non-interactive mode detected. Auto-selecting update option."
|
||||
if [[ "$UPDATE_CHANNEL" == "rc" ]] && [[ -n "$RC_VERSION" ]] && [[ "$RC_VERSION" != "$STABLE_VERSION" ]]; then
|
||||
choice=2 # RC version
|
||||
else
|
||||
choice=1 # Stable version
|
||||
fi
|
||||
automatic_choice=true
|
||||
fi
|
||||
|
||||
if [[ "$automatic_choice" == true ]]; then
|
||||
# Rows disappear when a release is unavailable or already installed.
|
||||
# Fixed option numbers could then select preview, reinstall or REMOVE.
|
||||
# Only a newer release in the requested channel is an automatic action.
|
||||
local automatic_release="$STABLE_VERSION"
|
||||
choice="$stable_choice"
|
||||
if [[ "$requested_channel" == rc ]]; then
|
||||
automatic_release="$RC_VERSION"
|
||||
if [[ "$RC_VERSION" == "$STABLE_VERSION" ]]; then
|
||||
choice="$stable_choice"
|
||||
else
|
||||
choice="$rc_choice"
|
||||
fi
|
||||
fi
|
||||
if [[ -z "$automatic_release" ]]; then
|
||||
print_error "Cannot determine a published Pulse server release for the $requested_channel channel. Existing installation is unchanged; check connectivity and jq, or deliberately choose an exact version with --version."
|
||||
return 1
|
||||
fi
|
||||
if [[ -n "$CURRENT_VERSION" && "$CURRENT_VERSION" != unknown ]]; then
|
||||
local automatic_compare=0
|
||||
compare_versions "$automatic_release" "$CURRENT_VERSION" || automatic_compare=$?
|
||||
case "$automatic_compare" in
|
||||
0)
|
||||
print_info "$CURRENT_VERSION is already the selected $requested_channel release. Existing installation is unchanged."
|
||||
return 0
|
||||
;;
|
||||
2)
|
||||
print_error "Selected release $automatic_release is older than $CURRENT_VERSION; refusing to downgrade automatically. Existing installation is unchanged; use --version only for a deliberate rollback."
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
if [[ -z "$choice" ]]; then
|
||||
print_error "No update option matches the selected release. Existing installation is unchanged."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Debug: Check if choice was read correctly
|
||||
if [[ -z "$choice" ]]; then
|
||||
print_error "No option selected. Exiting."
|
||||
@@ -5201,6 +5241,12 @@ main() {
|
||||
action="cancel"
|
||||
fi
|
||||
|
||||
# Preview can legitimately select the newest stable release. Do not
|
||||
# silently turn that requested preview channel into a stable preference.
|
||||
if [[ "$automatic_choice" == true && "$action" == update ]]; then
|
||||
UPDATE_CHANNEL="$requested_channel"
|
||||
fi
|
||||
|
||||
# Debug: Show what action was determined
|
||||
# print_info "DEBUG: Action determined: ${action:-'none'}"
|
||||
|
||||
@@ -5251,6 +5297,11 @@ main() {
|
||||
exit 0
|
||||
;;
|
||||
reinstall)
|
||||
if ! is_pulse_release_tag "$CURRENT_VERSION"; then
|
||||
print_error "Cannot reinstall an unknown current server version. Existing installation is unchanged; deliberately choose an exact version with --version."
|
||||
return 1
|
||||
fi
|
||||
LATEST_RELEASE="$CURRENT_VERSION"
|
||||
offer_existing_auto_updates
|
||||
|
||||
backup_existing
|
||||
|
||||
@@ -4514,6 +4514,19 @@ func TestRootInstallReleaseSelectionRejectsChartAndUnknownMetadata(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
// Exercise installer main only with confined transport and mutation doubles.
|
||||
// In particular, a mistaken remove choice exits before any real removal code.
|
||||
func TestRootInstallExistingMenuRequiresExactAutomaticIntent(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, "python3", repoFile("scripts", "tests", "test_server_update_menu.py"), "-v")
|
||||
cmd.Env = append(os.Environ(), "PULSE_INSTALLER_UNDER_TEST="+repoFile("install.sh"))
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("existing installer menu consent/target controls: %v\n%s", err, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootInstallStableReleaseTagRejectsPrereleaseShapes(t *testing.T) {
|
||||
script := `
|
||||
` + extractRootInstallShellFunction(t, "is_stable_release_tag") + `
|
||||
|
||||
@@ -793,7 +793,9 @@ func TestPrereleaseUpdateCopyUsesPreviewFraming(t *testing.T) {
|
||||
`Update to $RC_VERSION (prerelease preview)`,
|
||||
`--rc, --pre Install latest prerelease preview version`,
|
||||
`Prerelease channel detected in configuration`,
|
||||
`Prerelease channel: get latest release (including prereleases, but skip drafts)`,
|
||||
// Bind the menu to the complete server-release selector rather than
|
||||
// requiring a removed, non-user-facing implementation comment.
|
||||
`RC_VERSION=$(resolve_latest_release_tag_for_channel rc 2>/dev/null || true)`,
|
||||
}
|
||||
for _, needle := range requiredInstall {
|
||||
if !strings.Contains(installScript, needle) {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise the actual existing-installation menu without host mutations.
|
||||
|
||||
Release transport and all mutation boundaries are confined doubles. These are
|
||||
ordinary-user selection/consent controls, not a native installation proof.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
INSTALLER = Path(os.environ.get("PULSE_INSTALLER_UNDER_TEST", ROOT / "install.sh"))
|
||||
|
||||
|
||||
def release(tag, *, draft=False, prerelease=False):
|
||||
return {"tag_name": tag, "draft": draft, "prerelease": prerelease}
|
||||
|
||||
|
||||
HARNESS = r'''
|
||||
source "$INSTALLER"
|
||||
FORCE_VERSION="$PIN"
|
||||
FORCE_CHANNEL="$CHANNEL"
|
||||
CURRENT_VERSION="$INSTALLED"
|
||||
IN_CONTAINER=true
|
||||
IN_DOCKER="$DOCKER"
|
||||
print_header() { :; }
|
||||
print_info() { printf '%s\n' "$*"; }
|
||||
print_warn() { printf '%s\n' "$*"; }
|
||||
print_error() { printf '%s\n' "$*" >&2; }
|
||||
check_root() { :; }
|
||||
detect_os() { :; }
|
||||
check_docker_environment() { :; }
|
||||
check_existing_installation() { return 0; }
|
||||
check_proxmox_host() { return 1; }
|
||||
create_lxc_container() { exit 96; }
|
||||
timeout() { shift; "$@"; }
|
||||
curl() {
|
||||
local url="${@: -1}"
|
||||
printf '%s\n' "$url" >> "$FIXTURE/transport"
|
||||
case "$url" in
|
||||
https://api.github.com/repos/rcourtman/Pulse/releases)
|
||||
printf '%s' "$RELEASES_JSON"
|
||||
return "$API_EXIT" ;;
|
||||
https://github.com/rcourtman/Pulse/releases/latest)
|
||||
[[ -n "$REDIRECT_TAG" ]] || return 22
|
||||
printf 'https://github.com/rcourtman/Pulse/releases/tag/%s' "$REDIRECT_TAG" ;;
|
||||
*) printf 'unexpected transport: %s\n' "$url" >&2; exit 97 ;;
|
||||
esac
|
||||
}
|
||||
if [[ "$NO_JQ" == true ]]; then
|
||||
command() {
|
||||
[[ "$*" != '-v jq' ]] || return 1
|
||||
builtin command "$@"
|
||||
}
|
||||
fi
|
||||
safe_read() {
|
||||
[[ "$MENU_CHOICE" != eof ]] || return 1
|
||||
printf -v "$2" '%s' "$MENU_CHOICE"
|
||||
}
|
||||
# Every reachable mutation is stopped before the real implementation, including
|
||||
# removal. A wrong automatic choice is evidence, never an actual uninstall.
|
||||
offer_existing_auto_updates() { echo CONSENT_PROMPT >> "$FIXTURE/mutations"; }
|
||||
detect_service_name() { echo pulse; }
|
||||
run_upgrade_readiness_preflight() { echo PREFLIGHT >> "$FIXTURE/mutations"; }
|
||||
backup_existing() { echo BACKUP >> "$FIXTURE/mutations"; }
|
||||
create_user() { echo USER >> "$FIXTURE/mutations"; }
|
||||
download_pulse() {
|
||||
printf 'DOWNLOAD=%s CHANNEL=%s\n' "${LATEST_RELEASE:-unpinned}" "${UPDATE_CHANNEL:-}" >> "$FIXTURE/mutations"
|
||||
exit 0
|
||||
}
|
||||
quiesce_pulse_for_removal() { echo REMOVE >> "$FIXTURE/mutations"; exit 77; }
|
||||
main
|
||||
'''
|
||||
|
||||
|
||||
class ServerUpdateMenuTest(unittest.TestCase):
|
||||
def exercise(self, releases, installed="v6.5.0", channel="stable", *,
|
||||
choice="eof", docker=False, configured="stable", api_exit=0,
|
||||
redirect="", no_jq=False, pinned="", compact=False):
|
||||
payload = (releases if isinstance(releases, str) else
|
||||
json.dumps(releases, indent=None if compact else 2))
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
fixture = Path(temporary)
|
||||
config = fixture / "config"
|
||||
config.mkdir()
|
||||
(config / "system.json").write_text(json.dumps({"updateChannel": configured}))
|
||||
env = {**os.environ, "INSTALLER": str(INSTALLER), "FIXTURE": str(fixture),
|
||||
"PULSE_CONFIG_DIR": str(config), "RELEASES_JSON": payload,
|
||||
"INSTALLED": installed, "CHANNEL": channel, "MENU_CHOICE": choice,
|
||||
"DOCKER": str(docker).lower(), "API_EXIT": str(api_exit),
|
||||
"REDIRECT_TAG": redirect, "NO_JQ": str(no_jq).lower(), "PIN": pinned}
|
||||
result = subprocess.run(["bash", "-c", HARNESS], env=env,
|
||||
capture_output=True, text=True, timeout=15)
|
||||
def lines(name):
|
||||
return (fixture / name).read_text().splitlines() if (fixture / name).exists() else []
|
||||
return result, lines("mutations"), lines("transport")
|
||||
|
||||
def assert_unchanged(self, result, mutations, *, refused=False):
|
||||
self.assertEqual(result.returncode, 1 if refused else 0, result.stdout + result.stderr)
|
||||
self.assertEqual(mutations, [], result.stdout + result.stderr)
|
||||
|
||||
def assert_download(self, result, mutations, version, channel):
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
self.assertEqual(mutations[-1:], [f"DOWNLOAD={version} CHANNEL={channel}"])
|
||||
self.assertNotIn("REMOVE", mutations)
|
||||
|
||||
def test_current_preview_without_stable_never_autoselects_removal(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True)]
|
||||
for docker in (False, True):
|
||||
with self.subTest(docker=docker):
|
||||
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1", "rc", docker=docker)
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_preview_only_row_is_selected_by_its_actual_index(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
for docker in (False, True):
|
||||
r, mutations, _ = self.exercise(data, channel="rc", docker=docker)
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
|
||||
|
||||
def test_stable_at_current_version_does_not_switch_to_preview_or_reinstall(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
for docker in (False, True):
|
||||
r, mutations, _ = self.exercise(data, docker=docker)
|
||||
self.assert_unchanged(r, mutations)
|
||||
r, mutations, _ = self.exercise([release("v6.5.0")])
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_chart_and_draft_metadata_do_not_enter_the_menu(self):
|
||||
data = [release("helm-chart-6.7.0-rc.1", prerelease=True),
|
||||
release("v6.7.0-rc.1", draft=True, prerelease=True),
|
||||
release("helm-chart-6.6.0"), release("v6.6.0-rc.1", prerelease=True),
|
||||
release("v6.5.0")]
|
||||
for compact in (False, True):
|
||||
r, mutations, _ = self.exercise(data, channel="rc", compact=compact)
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
|
||||
self.assertNotIn("Update to helm-chart", r.stdout)
|
||||
self.assertNotIn("Update to v6.7.0", r.stdout)
|
||||
|
||||
def test_new_stable_and_preview_keep_distinct_targets(self):
|
||||
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
|
||||
for channel, target in (("stable", "v6.6.0"), ("rc", "v6.7.0-rc.1")):
|
||||
r, mutations, _ = self.exercise(data, channel=channel)
|
||||
self.assert_download(r, mutations, target, channel)
|
||||
|
||||
def test_preview_channel_can_follow_stable_without_losing_channel_intent(self):
|
||||
data = [release("v6.6.0"), release("v6.6.0-rc.1", prerelease=True)]
|
||||
r, mutations, _ = self.exercise(data, channel="rc")
|
||||
self.assert_download(r, mutations, "v6.6.0", "rc")
|
||||
|
||||
def test_configured_channel_and_explicit_override_are_respected(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, channel="", configured="rc")
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
|
||||
r, mutations, _ = self.exercise(data, channel="stable", configured="rc")
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_unavailable_or_malformed_metadata_never_falls_into_another_action(self):
|
||||
for payload in ([], "", "null", '[{"tag_name":"v6.6.0"',
|
||||
[release("helm-chart-6.6.0")], [release("v6.6.0", draft=True)]):
|
||||
for channel in ("stable", "rc"):
|
||||
with self.subTest(payload=payload, channel=channel):
|
||||
r, mutations, _ = self.exercise(payload, channel=channel)
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
self.assertIn("published Pulse server release", r.stderr)
|
||||
|
||||
def test_failed_api_payload_and_missing_parser_do_not_admit_preview(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
for options in ({"api_exit": 22}, {"no_jq": True}):
|
||||
r, mutations, _ = self.exercise(data, channel="rc", **options)
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
r, mutations, _ = self.exercise(data, channel="rc", redirect="v6.5.0", **options)
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_automatic_downgrade_is_refused_but_deliberate_menu_choice_is_retained(self):
|
||||
data = [release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1")
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
self.assertIn("refusing to downgrade automatically", r.stderr)
|
||||
r, mutations, _ = self.exercise(data, "v6.6.0-rc.1", choice="1")
|
||||
self.assert_download(r, mutations, "v6.5.0", "stable")
|
||||
|
||||
def test_equivalent_version_spelling_is_an_automatic_noop(self):
|
||||
r, mutations, _ = self.exercise([release("6.5.0")])
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_preview_revision_upgrade_uses_numeric_not_lexical_order(self):
|
||||
for stage in ("beta", "rc"):
|
||||
with self.subTest(stage=stage):
|
||||
target = f"v6.6.0-{stage}.10"
|
||||
data = [release(target, prerelease=True), release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, f"v6.6.0-{stage}.9", "rc")
|
||||
self.assert_download(r, mutations, target, "rc")
|
||||
r, mutations, _ = self.exercise(data, target, "rc")
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_preview_stage_and_reverse_revision_are_not_downgrades_in_disguise(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, "v6.6.0-beta.99", "rc")
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
|
||||
for target, installed in (("v6.6.0-rc.9", "v6.6.0-rc.10"),
|
||||
("v6.6.0-beta.99", "v6.6.0-rc.1")):
|
||||
data = [release(target, prerelease=True), release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, installed, "rc")
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
|
||||
def test_explicit_reinstall_pins_current_version_not_latest(self):
|
||||
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
|
||||
r, mutations, _ = self.exercise(data, choice="3")
|
||||
self.assert_download(r, mutations, "v6.5.0", "stable")
|
||||
r, mutations, _ = self.exercise(data, installed="unknown", choice="3")
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
|
||||
def test_deliberate_update_rows_remove_and_cancel_have_exact_meaning(self):
|
||||
data = [release("v6.7.0-rc.1", prerelease=True), release("v6.6.0")]
|
||||
for choice, target, channel in (("1", "v6.6.0", "stable"),
|
||||
("2", "v6.7.0-rc.1", "rc")):
|
||||
r, mutations, _ = self.exercise(data, choice=choice)
|
||||
self.assert_download(r, mutations, target, channel)
|
||||
r, mutations, _ = self.exercise(data, choice="4")
|
||||
self.assertEqual(r.returncode, 77)
|
||||
self.assertEqual(mutations, ["REMOVE"])
|
||||
r, mutations, _ = self.exercise(data, choice="5")
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_manual_rows_stay_correct_when_stable_update_is_absent(self):
|
||||
data = [release("v6.6.0-rc.1", prerelease=True), release("v6.5.0")]
|
||||
r, mutations, _ = self.exercise(data, choice="1")
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "rc")
|
||||
r, mutations, _ = self.exercise(data, choice="2")
|
||||
self.assert_download(r, mutations, "v6.5.0", "stable")
|
||||
r, mutations, _ = self.exercise(data, choice="3")
|
||||
self.assertEqual(r.returncode, 77)
|
||||
self.assertEqual(mutations, ["REMOVE"])
|
||||
r, mutations, _ = self.exercise(data, choice="4")
|
||||
self.assert_unchanged(r, mutations)
|
||||
|
||||
def test_invalid_or_empty_manual_choice_never_mutates(self):
|
||||
for choice in ("", "0", "99", "not-a-number"):
|
||||
r, mutations, _ = self.exercise([release("v6.6.0")], choice=choice)
|
||||
self.assert_unchanged(r, mutations, refused=True)
|
||||
|
||||
def test_explicit_version_bypasses_menu_and_discovery(self):
|
||||
r, mutations, transport = self.exercise([], pinned="v6.6.0-rc.1")
|
||||
self.assert_download(r, mutations, "v6.6.0-rc.1", "")
|
||||
self.assertEqual(transport, [])
|
||||
self.assertNotIn("What would you like to do?", r.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user