From 66fdc23853420fa799980fb13b3d0b42ff29d483 Mon Sep 17 00:00:00 2001 From: "Earl Tankard, Jr., Ph.D" <45021016+primetimetank21@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:24:43 -0400 Subject: [PATCH 1/2] feat(setup): add interactive mode guards (#495) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/validate.yml | 8 + scripts/dev/regenerate-baseline-fixtures.sh | 12 +- scripts/linux/setup.sh | 72 +++++- scripts/windows/setup.ps1 | 79 ++++++- setup.ps1 | 13 +- tests/fixtures/stub-tools/linux/selection.txt | 3 + .../fixtures/stub-tools/windows/selection.txt | 3 + tests/test_setup_flags.sh | 207 ++++++++++++++++-- tests/test_setup_flags_pwsh.ps1 | 147 ++++++++++++- 9 files changed, 517 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/stub-tools/linux/selection.txt create mode 100644 tests/fixtures/stub-tools/windows/selection.txt diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index d0c2a30..e41c924 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -230,6 +230,9 @@ jobs: - name: Lint config/dotfiles/.aliases run: shellcheck -s bash config/dotfiles/.aliases + - name: Run Bash flag compatibility gates + run: bash tests/test_setup_flags.sh + lint-powershell: name: Lint PowerShell Scripts runs-on: ubuntu-latest @@ -350,6 +353,11 @@ jobs: run: | powershell -ExecutionPolicy Bypass -File tests\test_windows_setup.ps1 + - name: Run PS 5.1 flag compatibility gates + shell: powershell + run: | + powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1 + - name: Configure git hooks path shell: powershell run: | diff --git a/scripts/dev/regenerate-baseline-fixtures.sh b/scripts/dev/regenerate-baseline-fixtures.sh index 041451c..8748245 100644 --- a/scripts/dev/regenerate-baseline-fixtures.sh +++ b/scripts/dev/regenerate-baseline-fixtures.sh @@ -19,6 +19,10 @@ LINUX_FIXTURE="${REPO_ROOT}/tests/fixtures/baseline-tools-linux.txt" WIN_FIXTURE="${REPO_ROOT}/tests/fixtures/baseline-tools-windows.txt" LINUX_SETUP="${REPO_ROOT}/scripts/linux/setup.sh" WIN_SETUP="${REPO_ROOT}/scripts/windows/setup.ps1" +WIN_SETUP_PS="$WIN_SETUP" +if command -v cygpath >/dev/null 2>&1; then + WIN_SETUP_PS="$(cygpath -w "$WIN_SETUP")" +fi # Extract Linux DEFAULT_TOOLS from source (bash array literal) extract_linux() { @@ -29,24 +33,24 @@ extract_linux() { extract_windows() { if command -v pwsh >/dev/null 2>&1; then pwsh -NoProfile -Command " - \$content = Get-Content '$WIN_SETUP' -Raw + \$content = Get-Content '$WIN_SETUP_PS' -Raw if (\$content -match '(?s)\\\$DefaultTools\s*=\s*@\((.*?)\)') { \$block = \$Matches[1] \$block.Split([char[]]@([char]13,[char]10)) | ForEach-Object { \$_.Trim().Trim(\"'\").Trim('\"') } | Where-Object { \$_ -and \$_ -notmatch '^#' } } - " + " | tr -d '\r' elif command -v powershell >/dev/null 2>&1; then powershell -NoProfile -Command " - \$content = Get-Content '$WIN_SETUP' -Raw + \$content = Get-Content '$WIN_SETUP_PS' -Raw if (\$content -match '(?s)\\\$DefaultTools\s*=\s*@\((.*?)\)') { \$block = \$Matches[1] \$block.Split([char[]]@([char]13,[char]10)) | ForEach-Object { \$_.Trim().Trim(\"'\").Trim('\"') } | Where-Object { \$_ -and \$_ -notmatch '^#' } } - " + " | tr -d '\r' else echo "ERROR: pwsh/powershell not found -- cannot extract Windows defaults" >&2 exit 1 diff --git a/scripts/linux/setup.sh b/scripts/linux/setup.sh index 72d8d54..39e8e98 100755 --- a/scripts/linux/setup.sh +++ b/scripts/linux/setup.sh @@ -5,16 +5,22 @@ # # Usage (direct): # bash scripts/linux/setup.sh [--list] [--help] [--only=a,b] [--skip=a,b] +# [--interactive | --non-interactive] # # Flags: # --list Print available tools (alphabetical), exit 0. No install. # --help Print usage, exit 0. # --only=a,b,c Install ONLY the listed tools (comma-separated). # --skip=a,b,c Install all default tools EXCEPT the listed ones. +# --interactive Show the tool picker when a TTY is available. +# --non-interactive +# Never show the tool picker. # --only and --skip are mutually exclusive. # # Hidden test seam (not in --help): # --tools-dir= Override the tools directory (test use only). +# --selection-file= +# Read selected tools from a file (test use only). set -euo pipefail exec 2>&1 # Merge stderr into stdout for ordered output in piped/Devcontainer environments @@ -52,8 +58,12 @@ ARG_SKIP="" ARG_LIST=0 ARG_HELP=0 ARG_TOOLS_DIR="" # hidden test seam +ARG_SELECTION_FILE="" # hidden test seam ARG_ONLY_SET=0 # tracks whether --only was explicitly provided ARG_SKIP_SET=0 # tracks whether --skip was explicitly provided +ARG_INTERACTIVE_SET=0 +ARG_NON_INTERACTIVE_SET=0 +ARG_SELECTION_FILE_SET=0 for arg in "$@"; do case "$arg" in @@ -61,7 +71,13 @@ for arg in "$@"; do --skip=*) ARG_SKIP="${arg#--skip=}"; ARG_SKIP_SET=1 ;; --list) ARG_LIST=1 ;; --help) ARG_HELP=1 ;; + --interactive) ARG_INTERACTIVE_SET=1 ;; + --non-interactive) ARG_NON_INTERACTIVE_SET=1 ;; --tools-dir=*) ARG_TOOLS_DIR="${arg#--tools-dir=}" ;; + --selection-file=*) + ARG_SELECTION_FILE="${arg#--selection-file=}" + ARG_SELECTION_FILE_SET=1 + ;; *) log_error "Unknown argument: $arg" log_error "Run with --help for usage." @@ -137,6 +153,8 @@ Options: --help Print this help message, exit 0. --only=a,b,c Install ONLY the listed tools (comma-separated). --skip=a,b,c Install all default tools EXCEPT the listed ones. + --interactive Show the tool picker when a TTY is available. + --non-interactive Never show the tool picker. Notes: --only and --skip are mutually exclusive. @@ -166,8 +184,39 @@ if [[ $ARG_ONLY_SET -eq 1 && $ARG_SKIP_SET -eq 1 ]]; then exit 1 fi +if [[ $ARG_INTERACTIVE_SET -eq 1 && $ARG_NON_INTERACTIVE_SET -eq 1 ]]; then + log_error "--interactive and --non-interactive are mutually exclusive." + exit 1 +fi + +if [[ $ARG_NON_INTERACTIVE_SET -eq 1 && $ARG_SELECTION_FILE_SET -eq 1 ]]; then + log_error "--non-interactive and --selection-file are mutually exclusive." + exit 1 +fi + # (Note: ARG_ONLY_SET / ARG_SKIP_SET handle empty-value sentinels; build_final_toolset validates.) +# --------------------------------------------------------------------------- +# Interactive guard. Slice 1 only detects whether a future menu may run. +# No menu is invoked until Slice 2. +# --------------------------------------------------------------------------- +is_interactive() { + if [[ $ARG_NON_INTERACTIVE_SET -eq 1 || $ARG_ONLY_SET -eq 1 || $ARG_SKIP_SET -eq 1 ]]; then + return 1 + fi + if [[ "${SETUP_NON_INTERACTIVE:-}" == "1" || -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" ]]; then + return 1 + fi + if [[ ! -t 0 || ! -t 1 ]]; then + return 1 + fi + return 0 +} + +if is_interactive; then + : # ponytail: detection-only ceiling for Slice 1; Slice 2 wires the Bash menu here. +fi + # --------------------------------------------------------------------------- # Build FinalToolSet -- populates global FINAL_TOOLS (bash 3.2 safe: no # local -n namerefs, no mapfile; use plain global array + while-read). @@ -176,6 +225,24 @@ FINAL_TOOLS=() build_final_toolset() { FINAL_TOOLS=() + if [[ $ARG_SELECTION_FILE_SET -eq 1 && $ARG_ONLY_SET -eq 0 && $ARG_SKIP_SET -eq 0 ]]; then + if [[ -z "$ARG_SELECTION_FILE" || ! -f "$ARG_SELECTION_FILE" ]]; then + log_error "Selection file not found: ${ARG_SELECTION_FILE}" + exit 1 + fi + local _selection + while IFS= read -r _selection || [[ -n "$_selection" ]]; do + _selection="${_selection%$'\r'}" + [[ -z "$_selection" ]] && continue + if [[ -n "$ARG_ONLY" ]]; then + ARG_ONLY="${ARG_ONLY},${_selection}" + else + ARG_ONLY="$_selection" + fi + done < "$ARG_SELECTION_FILE" + ARG_ONLY_SET=1 + fi + # Build available-tools list into a local array (while-read, not mapfile) local available=() local _t @@ -256,7 +323,9 @@ build_final_toolset() { for s in "${skip_list[@]}"; do [[ "$s" == "$tool" ]] && skip=1 && break done - [[ $skip -eq 0 ]] && FINAL_TOOLS+=("$tool") + if [[ $skip -eq 0 ]]; then + FINAL_TOOLS+=("$tool") + fi done else @@ -301,4 +370,3 @@ main() { } main - diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 4634ce8..8caead0 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -10,10 +10,14 @@ # -Help Print usage, exit 0. # -Only "a,b,c" Install ONLY the listed tools (comma-separated). # -Skip "a,b,c" Install all default tools EXCEPT the listed ones. +# -Interactive Show the tool picker when a console is available. +# -NonInteractive Never show the tool picker. # -Only and -Skip are mutually exclusive. # # Hidden test seam (not in -Help): # -ToolsDir Override the tools directory (test use only). +# -SelectionFile +# Read selected tools from a file (test use only). # # PS 5.1 ASCII-only: no smart quotes, em-dashes, or non-ASCII characters. @@ -23,7 +27,10 @@ param( [string]$Skip = '', [switch]$List, [switch]$Help, - [string]$ToolsDir = '' + [switch]$Interactive, + [switch]$NonInteractive, + [string]$ToolsDir = '', + [string]$SelectionFile = '' ) Set-StrictMode -Version Latest @@ -153,6 +160,8 @@ function Show-Help { Write-Output " -Help Print this help message, exit 0." Write-Output " -Only 'a,b,c' Install ONLY the listed tools (comma-separated)." Write-Output " -Skip 'a,b,c' Install all default tools EXCEPT the listed ones." + Write-Output " -Interactive Show the tool picker when a console is available." + Write-Output " -NonInteractive Never show the tool picker." Write-Output "" Write-Output "Notes:" Write-Output " -Only and -Skip are mutually exclusive." @@ -181,13 +190,79 @@ if ($PSBoundParameters.ContainsKey('Only') -and $PSBoundParameters.ContainsKey(' exit 1 } +if ($Interactive -and $NonInteractive) { + Write-Err "-Interactive and -NonInteractive are mutually exclusive." + exit 1 +} + +if ($NonInteractive -and $PSBoundParameters.ContainsKey('SelectionFile')) { + Write-Err "-NonInteractive and -SelectionFile are mutually exclusive." + exit 1 +} + +# --------------------------------------------------------------------------- +# Interactive guard. Slice 1 only detects whether a future menu may run. +# No menu is invoked until Slice 3. +# --------------------------------------------------------------------------- +function Test-ShouldShowMenu { + param( + [bool]$NonInteractiveRequested, + [bool]$OnlySet, + [bool]$SkipSet + ) + if ($NonInteractiveRequested -or $OnlySet -or $SkipSet) { return $false } + if ($env:SETUP_NON_INTERACTIVE -eq '1' -or $env:CI -or $env:GITHUB_ACTIONS) { return $false } + if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } + if ($null -eq $Host.UI.RawUI) { return $false } + return $true +} + +# ponytail: detection-only ceiling for Slice 1; Slice 3 wires the PowerShell menu here. +$null = Test-ShouldShowMenu ` + -NonInteractiveRequested $NonInteractive.IsPresent ` + -OnlySet ($PSBoundParameters.ContainsKey('Only')) ` + -SkipSet ($PSBoundParameters.ContainsKey('Skip')) + # --------------------------------------------------------------------------- # Build FinalToolSet # --------------------------------------------------------------------------- $FinalTools = @() $Available = Get-AvailableTool +$SelectionNames = @() +$UseSelectionFile = $PSBoundParameters.ContainsKey('SelectionFile') -and + -not $PSBoundParameters.ContainsKey('Only') -and + -not $PSBoundParameters.ContainsKey('Skip') + +if ($UseSelectionFile) { + if ([string]::IsNullOrEmpty($SelectionFile) -or -not (Test-Path -LiteralPath $SelectionFile -PathType Leaf)) { + Write-Err "Selection file not found: $SelectionFile" + exit 1 + } + $SelectionNames = @(Get-Content -LiteralPath $SelectionFile | Where-Object { $_ -ne '' }) +} + +if ($UseSelectionFile) { + if ($SelectionNames.Count -eq 0) { + Write-Err "Flag requires at least one tool name." + exit 1 + } + $names = $SelectionNames + foreach ($name in $names) { + if ($Available -notcontains $name) { + Write-Err "Unknown tool: $name" + Write-Err "Available tools: $($Available -join ', ')" + exit 1 + } + } + foreach ($tool in $DefaultTools) { + if ($names -contains $tool) { + $FinalTools += $tool + } + } + $optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) + foreach ($t in $optIn) { $FinalTools += $t } -if ($PSBoundParameters.ContainsKey('Only')) { +} elseif ($PSBoundParameters.ContainsKey('Only')) { $names = Split-ToolList -ToolList $Only foreach ($name in $names) { if ($Available -notcontains $name) { diff --git a/setup.ps1 b/setup.ps1 index 2bc0551..89a7294 100644 --- a/setup.ps1 +++ b/setup.ps1 @@ -6,7 +6,8 @@ # Usage: # powershell -ExecutionPolicy Bypass -File setup.ps1 [OPTIONS] # -# Flags forwarded to scripts\windows\setup.ps1: -List, -Help, -Only, -Skip, -ToolsDir +# Flags forwarded to scripts\windows\setup.ps1: +# -List, -Help, -Only, -Skip, -Interactive, -NonInteractive # # For Linux/macOS/WSL, use setup.sh instead. @@ -16,7 +17,10 @@ param( [string]$Skip = '', [switch]$List, [switch]$Help, - [string]$ToolsDir = '' + [switch]$Interactive, + [switch]$NonInteractive, + [string]$ToolsDir = '', + [string]$SelectionFile = '' ) Set-StrictMode -Version Latest @@ -31,7 +35,12 @@ if ($PSBoundParameters.ContainsKey('Only')) { $_fwdParams['Only'] = $Only } if ($PSBoundParameters.ContainsKey('Skip')) { $_fwdParams['Skip'] = $Skip } if ($List.IsPresent) { $_fwdParams['List'] = $true } if ($Help.IsPresent) { $_fwdParams['Help'] = $true } +if ($Interactive.IsPresent) { $_fwdParams['Interactive'] = $true } +if ($NonInteractive.IsPresent) { $_fwdParams['NonInteractive'] = $true } if ($ToolsDir) { $_fwdParams['ToolsDir'] = $ToolsDir } +if ($PSBoundParameters.ContainsKey('SelectionFile')) { + $_fwdParams['SelectionFile'] = $SelectionFile +} # -- Logging helpers ----------------------------------------------------------- diff --git a/tests/fixtures/stub-tools/linux/selection.txt b/tests/fixtures/stub-tools/linux/selection.txt new file mode 100644 index 0000000..6b0753d --- /dev/null +++ b/tests/fixtures/stub-tools/linux/selection.txt @@ -0,0 +1,3 @@ +delta + +alpha diff --git a/tests/fixtures/stub-tools/windows/selection.txt b/tests/fixtures/stub-tools/windows/selection.txt new file mode 100644 index 0000000..6b0753d --- /dev/null +++ b/tests/fixtures/stub-tools/windows/selection.txt @@ -0,0 +1,3 @@ +delta + +alpha diff --git a/tests/test_setup_flags.sh b/tests/test_setup_flags.sh index 5fa2581..3d7b343 100644 --- a/tests/test_setup_flags.sh +++ b/tests/test_setup_flags.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash -# tests/test_setup_flags.sh -- WI-1 baseline + WI-2 --only tests (#468) +# tests/test_setup_flags.sh -- setup flag tests (#468, #495) # # Tests the framework spine: DEFAULT_TOOLS constant, --tools-dir seam, # --list, --help, root forwarding, baseline-diff. # WI-2: --only selective install with ORDER PRESERVATION invariant. +# #495 Slice 1: interactive mode guards and backward-compat drift gates. # # Usage: bash tests/test_setup_flags.sh # Requires: bash 3.2+ (macOS compatible), GNU diff @@ -23,6 +24,7 @@ REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" LINUX_SETUP="${REPO_ROOT}/scripts/linux/setup.sh" ROOT_SETUP="${REPO_ROOT}/setup.sh" STUB_DIR="${REPO_ROOT}/tests/fixtures/stub-tools/linux" +SELECTION_FILE="${STUB_DIR}/selection.txt" pass() { echo -e "${GREEN}PASS${RESET}: $1"; PASS=$((PASS + 1)); } fail() { echo -e "${RED}FAIL${RESET}: $1"; FAIL=$((FAIL + 1)); } @@ -54,13 +56,13 @@ assert_log_equals() { assert_contains() { local haystack="$1" local needle="$2" - echo "$haystack" | grep -qF "$needle" + echo "$haystack" | grep -qF -- "$needle" } assert_not_contains() { local haystack="$1" local needle="$2" - ! echo "$haystack" | grep -qF "$needle" + ! echo "$haystack" | grep -qF -- "$needle" } # --------------------------------------------------------------------------- @@ -69,7 +71,7 @@ assert_not_contains() { echo "" echo "--- T_baseline_noarg ---" setup_harness -if bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" 2>&1 | grep -q .; then : ; fi +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true if assert_log_equals "${STUB_DIR}/defaults.txt"; then pass "T_baseline_noarg: no-arg run logs exactly defaults.txt order" else @@ -224,7 +226,7 @@ assert_log_str() { echo "" echo "--- T_only_single ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha >/dev/null 2>&1 || true if assert_log_str "alpha"; then pass "T_only_single: --only=alpha logs only alpha" else @@ -238,7 +240,7 @@ teardown_harness echo "" echo "--- T_only_multi ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha,bravo 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha,bravo >/dev/null 2>&1 || true if assert_log_str "$(printf 'alpha\nbravo')"; then pass "T_only_multi: --only=alpha,bravo logs alpha then bravo (default order)" else @@ -254,7 +256,7 @@ teardown_harness echo "" echo "--- T_only_order_preserved ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=bravo,alpha 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=bravo,alpha >/dev/null 2>&1 || true if assert_log_str "$(printf 'alpha\nbravo')"; then pass "T_only_order_preserved: reversed input yields default order (alpha then bravo)" else @@ -268,7 +270,7 @@ teardown_harness echo "" echo "--- T_only_optin ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta >/dev/null 2>&1 || true if assert_log_str "delta"; then pass "T_only_optin: --only=delta (opt-in tool) works" else @@ -283,7 +285,7 @@ teardown_harness echo "" echo "--- T_only_optin_order ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta,alpha 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=delta,alpha >/dev/null 2>&1 || true if assert_log_str "$(printf 'alpha\ndelta')"; then pass "T_only_optin_order: default tool (alpha) before opt-in tool (delta)" else @@ -381,7 +383,7 @@ echo "" echo "--- T_root_only ---" if [[ -f "$ROOT_SETUP" ]]; then setup_harness - bash "$ROOT_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha 2>&1 | grep -q . || true + bash "$ROOT_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha >/dev/null 2>&1 || true if assert_log_str "alpha"; then pass "T_root_only: root setup.sh --only=alpha forwards and installs only alpha" else @@ -399,7 +401,7 @@ fi echo "" echo "--- T_backward_compat_gate ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true if assert_log_equals "${STUB_DIR}/defaults.txt"; then pass "T_backward_compat_gate: no-arg run still logs all defaults in order" else @@ -419,7 +421,7 @@ teardown_harness echo "" echo "--- T_skip_single ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=bravo 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=bravo >/dev/null 2>&1 || true if assert_log_str "$(printf 'prereqs\nalpha\ncharlie\ndotfiles\ngit-hook')"; then pass "T_skip_single: --skip=bravo excludes bravo; remaining tools installed in order" else @@ -433,7 +435,7 @@ teardown_harness echo "" echo "--- T_skip_multi ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=alpha,charlie 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=alpha,charlie >/dev/null 2>&1 || true if assert_log_str "$(printf 'prereqs\nbravo\ndotfiles\ngit-hook')"; then pass "T_skip_multi: --skip=alpha,charlie excludes both; order preserved" else @@ -553,10 +555,10 @@ teardown_harness echo "" echo "--- T_no_selection_persistence ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --only=alpha >/dev/null 2>&1 || true teardown_harness setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true if assert_log_equals "${STUB_DIR}/defaults.txt"; then pass "T_no_selection_persistence: no-arg run after --only run installs full defaults" else @@ -570,7 +572,7 @@ teardown_harness echo "" echo "--- T_git_hook_skip_path_safe ---" setup_harness -bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=git-hook 2>&1 | grep -q . || true +bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" --skip=git-hook >/dev/null 2>&1 || true hook_log="$(cat "$RUN_LOG" 2>/dev/null || true)" if echo "$hook_log" | grep -qF "git-hook"; then fail "T_git_hook_skip_path_safe: git-hook appeared in run-log despite being skipped" @@ -581,6 +583,179 @@ else fi teardown_harness +# --------------------------------------------------------------------------- +# #495 Slice 1: interactive guard + backward-compat drift gates +# --------------------------------------------------------------------------- + +interactive_fn="$(awk '/^is_interactive\(\)/,/^}/' "$LINUX_SETUP")" + +echo "" +echo "--- T_menu_ci_skip ---" +if ( + eval "$interactive_fn" + # shellcheck disable=SC2034 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + # shellcheck disable=SC2034 + CI=true GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' + ! is_interactive +); then + pass "T_menu_ci_skip: CI suppresses interactive mode" +else + fail "T_menu_ci_skip: CI did not suppress interactive mode" +fi + +echo "" +echo "--- T_menu_tty_skip ---" +if ( + eval "$interactive_fn" + # shellcheck disable=SC2034 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + # shellcheck disable=SC2034 + CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' + ! is_interactive +); then + pass "T_menu_tty_skip: redirected test harness suppresses interactive mode" +else + fail "T_menu_tty_skip: redirected test harness was treated as interactive" +fi + +echo "" +echo "--- T_menu_non_interactive_flag ---" +if ( + eval "$interactive_fn" + # shellcheck disable=SC2034 + ARG_NON_INTERACTIVE_SET=1 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + # shellcheck disable=SC2034 + CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' + ! is_interactive +); then + pass "T_menu_non_interactive_flag: explicit flag suppresses interactive mode" +else + fail "T_menu_non_interactive_flag: explicit flag did not suppress interactive mode" +fi + +echo "" +echo "--- T_menu_only_suppresses_guard ---" +if ( + eval "$interactive_fn" + # shellcheck disable=SC2034 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=1 ARG_SKIP_SET=0 + # shellcheck disable=SC2034 + CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' + ! is_interactive +); then + pass "T_menu_only_suppresses_guard: --only suppresses interactive mode" +else + fail "T_menu_only_suppresses_guard: --only did not suppress interactive mode" +fi + +echo "" +echo "--- T_noarg_noninteractive_compat ---" +setup_harness +CI=true bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_noarg_noninteractive_compat: CI no-arg run matches defaults" +else + fail "T_noarg_noninteractive_compat: CI no-arg run drifted" +fi +teardown_harness + +echo "" +echo "--- T_noninteractive_flag_compat ---" +setup_harness +bash "$LINUX_SETUP" --non-interactive "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_noninteractive_flag_compat: --non-interactive run matches defaults" +else + fail "T_noninteractive_flag_compat: --non-interactive run drifted" +fi +teardown_harness + +echo "" +echo "--- T_noninteractive_env_var_compat ---" +setup_harness +SETUP_NON_INTERACTIVE=1 bash "$LINUX_SETUP" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_noninteractive_env_var_compat: env-guarded run matches defaults" +else + fail "T_noninteractive_env_var_compat: env-guarded run drifted" +fi +teardown_harness + +echo "" +echo "--- T_menu_mutual_exclusion ---" +bash "$LINUX_SETUP" --interactive --non-interactive >/dev/null 2>&1 && menu_conflict_exit=0 || menu_conflict_exit=$? +if [[ $menu_conflict_exit -ne 0 ]]; then + pass "T_menu_mutual_exclusion: interactive flags conflict" +else + fail "T_menu_mutual_exclusion: conflicting flags exited 0" +fi + +echo "" +echo "--- T_menu_help_flags_and_no_seam ---" +menu_help="$(bash "$LINUX_SETUP" --help 2>&1)" || true +if assert_contains "$menu_help" "--interactive" && \ + assert_contains "$menu_help" "--non-interactive" && \ + assert_not_contains "$menu_help" "selection-file" && \ + assert_not_contains "$menu_help" "tools-dir"; then + pass "T_menu_help_flags_and_no_seam: public flags shown; hidden seams absent" +else + fail "T_menu_help_flags_and_no_seam: help visibility contract failed" +fi + +echo "" +echo "--- T_selection_file_passthrough ---" +setup_harness +bash "$LINUX_SETUP" --interactive "--selection-file=${SELECTION_FILE}" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_str "$(printf 'alpha\ndelta')"; then + pass "T_selection_file_passthrough: selection file resolves in canonical order" +else + fail "T_selection_file_passthrough: selection file did not control dispatch" +fi +teardown_harness + +echo "" +echo "--- T_selection_file_noninteractive_conflict ---" +bash "$LINUX_SETUP" --non-interactive "--selection-file=${SELECTION_FILE}" >/dev/null 2>&1 && seam_conflict_exit=0 || seam_conflict_exit=$? +if [[ $seam_conflict_exit -ne 0 ]]; then + pass "T_selection_file_noninteractive_conflict: non-interactive rejects selection seam" +else + fail "T_selection_file_noninteractive_conflict: invalid seam combination exited 0" +fi + +echo "" +echo "--- T_menu_only_suppresses_menu ---" +setup_harness +bash "$LINUX_SETUP" --interactive --only=alpha "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_str "alpha"; then + pass "T_menu_only_suppresses_menu: explicit selection wins" +else + fail "T_menu_only_suppresses_menu: --interactive changed --only behavior" +fi +teardown_harness + +echo "" +echo "--- T_root_interactive_passthrough ---" +setup_harness +bash "$ROOT_SETUP" --interactive "--selection-file=${SELECTION_FILE}" "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_str "$(printf 'alpha\ndelta')"; then + pass "T_root_interactive_passthrough: root forwards interactive selection flags" +else + fail "T_root_interactive_passthrough: root forwarding failed" +fi +teardown_harness + +echo "" +echo "--- T_root_noninteractive_passthrough ---" +setup_harness +bash "$ROOT_SETUP" --non-interactive "--tools-dir=${STUB_DIR}" >/dev/null 2>&1 || true +if assert_log_equals "${STUB_DIR}/defaults.txt"; then + pass "T_root_noninteractive_passthrough: root forwards non-interactive flag" +else + fail "T_root_noninteractive_passthrough: root forwarding changed defaults" +fi +teardown_harness + # --------------------------------------------------------------------------- # Results # --------------------------------------------------------------------------- diff --git a/tests/test_setup_flags_pwsh.ps1 b/tests/test_setup_flags_pwsh.ps1 index f004611..c5a22ec 100644 --- a/tests/test_setup_flags_pwsh.ps1 +++ b/tests/test_setup_flags_pwsh.ps1 @@ -1,8 +1,9 @@ -# tests/test_setup_flags_pwsh.ps1 -- WI-1 baseline + WI-2 -Only tests (#468) +# tests/test_setup_flags_pwsh.ps1 -- setup flag tests (#468, #495) # # Tests the framework spine: $DefaultTools constant, -ToolsDir seam, # -List, -Help, root forwarding, baseline-diff. # WI-2: -Only selective install with ORDER PRESERVATION invariant. +# #495 Slice 1: interactive mode guards and backward-compat drift gates. # # Usage: powershell -ExecutionPolicy Bypass -File tests\test_setup_flags_pwsh.ps1 # PS 5.1 ASCII-only: no smart quotes, em-dashes, arrows, or emoji. @@ -16,6 +17,7 @@ $RepoRoot = Split-Path $PSScriptRoot -Parent $WinSetup = Join-Path $RepoRoot 'scripts\windows\setup.ps1' $RootSetup = Join-Path $RepoRoot 'setup.ps1' $StubDir = Join-Path $RepoRoot 'tests\fixtures\stub-tools\windows' +$SelectionFile = Join-Path $StubDir 'selection.txt' $BaselineFixture = Join-Path $RepoRoot 'tests\fixtures\baseline-tools-windows.txt' # --------------------------------------------------------------------------- @@ -606,6 +608,149 @@ Test-Scenario "T_git_hook_skip_path_safe: -Skip 'git-hook' succeeds; git-hook ex finally { Teardown-Harness } } +# --------------------------------------------------------------------------- +# #495 Slice 1: interactive guard + backward-compat drift gates +# --------------------------------------------------------------------------- + +$tokens = $null +$parseErrors = $null +$setupAst = [System.Management.Automation.Language.Parser]::ParseFile( + $WinSetup, + [ref]$tokens, + [ref]$parseErrors +) +$menuGuardAst = $setupAst.Find({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -eq 'Test-ShouldShowMenu' +}, $true) +if ($null -eq $menuGuardAst) { + throw "Test-ShouldShowMenu function not found" +} +. ([scriptblock]::Create($menuGuardAst.Extent.Text)) + +Test-Scenario "T_menu_ci_skip_ps: CI suppresses interactive mode" { + $oldCi = $env:CI + try { + $env:CI = 'true' + if (Test-ShouldShowMenu -NonInteractiveRequested $false -OnlySet $false -SkipSet $false) { + throw "CI was treated as interactive" + } + } + finally { $env:CI = $oldCi } +} + +Test-Scenario "T_menu_non_interactive_flag_ps: explicit flag suppresses interactive mode" { + if (Test-ShouldShowMenu -NonInteractiveRequested $true -OnlySet $false -SkipSet $false) { + throw "-NonInteractive was treated as interactive" + } +} + +Test-Scenario "T_menu_only_suppresses_guard_ps: -Only suppresses interactive mode" { + if (Test-ShouldShowMenu -NonInteractiveRequested $false -OnlySet $true -SkipSet $false) { + throw "-Only was treated as interactive" + } +} + +Test-Scenario "T_noarg_noninteractive_compat_ps: CI no-arg run matches defaults" { + Setup-Harness + $oldCi = $env:CI + try { + $env:CI = 'true' + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogEquals (Join-Path $StubDir 'defaults.txt') + } + finally { + $env:CI = $oldCi + Teardown-Harness + } +} + +Test-Scenario "T_noninteractive_flag_compat_ps: -NonInteractive run matches defaults" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -NonInteractive -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogEquals (Join-Path $StubDir 'defaults.txt') + } + finally { Teardown-Harness } +} + +Test-Scenario "T_noninteractive_env_var_compat_ps: env-guarded run matches defaults" { + Setup-Harness + $oldNonInteractive = $env:SETUP_NON_INTERACTIVE + try { + $env:SETUP_NON_INTERACTIVE = '1' + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogEquals (Join-Path $StubDir 'defaults.txt') + } + finally { + $env:SETUP_NON_INTERACTIVE = $oldNonInteractive + Teardown-Harness + } +} + +Test-Scenario "T_menu_mutual_exclusion_ps: interactive flags conflict" { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -Interactive -NonInteractive 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { throw "Conflicting interactive flags exited 0" } +} + +Test-Scenario "T_menu_help_flags_and_no_seam_ps: public flags shown; hidden seams absent" { + $out = powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup -Help 2>&1 | Out-String + if ($out -notmatch '-Interactive') { throw "-Help omits -Interactive" } + if ($out -notmatch '-NonInteractive') { throw "-Help omits -NonInteractive" } + if ($out -match 'SelectionFile|ToolsDir') { throw "-Help exposes hidden test seam" } +} + +Test-Scenario "T_selection_file_passthrough_ps: selection file resolves in canonical order" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -Interactive -SelectionFile $SelectionFile -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogStr @('alpha', 'delta') + } + finally { Teardown-Harness } +} + +Test-Scenario "T_selection_file_noninteractive_conflict_ps: non-interactive rejects seam" { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -NonInteractive -SelectionFile $SelectionFile 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { throw "Invalid seam combination exited 0" } +} + +Test-Scenario "T_menu_only_suppresses_menu_ps: explicit selection wins" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $WinSetup ` + -Interactive -Only 'alpha' -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogStr @('alpha') + } + finally { Teardown-Harness } +} + +Test-Scenario "T_root_interactive_passthrough_ps: root forwards interactive selection flags" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $RootSetup ` + -Interactive -SelectionFile $SelectionFile -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogStr @('alpha', 'delta') + } + finally { Teardown-Harness } +} + +Test-Scenario "T_root_noninteractive_passthrough_ps: root forwards non-interactive flag" { + Setup-Harness + try { + powershell -NoProfile -ExecutionPolicy Bypass -File $RootSetup ` + -NonInteractive -ToolsDir $StubDir 2>&1 | Out-Null + Assert-LogEquals (Join-Path $StubDir 'defaults.txt') + } + finally { Teardown-Harness } +} + # --------------------------------------------------------------------------- # Results # --------------------------------------------------------------------------- From 10931f634a247ea525ac760fcc09be3531a19992 Mon Sep 17 00:00:00 2001 From: "Earl Tankard, Jr., Ph.D" <45021016+primetimetank21@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:44:59 -0400 Subject: [PATCH 2/2] fix(setup): address reviewer findings on interactive-guard tests and PS duplication (#495) - Rewrite 4 bash skip-path unit tests to capture exact exit status 1 instead of using '! is_interactive'; add T_menu_skip_guards_127 mutation guard proving exit-127 != exit-1 (guards against missing- function false-pass) - Add --interactive + --selection-file bypass in is_interactive and Test-ShouldShowMenu so CI can test the menu path without a TTY; precedence: non-interactive/only/skip still suppress it first - Add T_menu_selection_file_ci_bypass (Bash) and T_menu_selection_file_ci_bypass_ps (PS) proving bypass under CI - Remove duplicate PS selection-file resolution block; validate file, join names, route through existing -Only path (Split-ToolList + order-preservation + opt-in alphabetic append) - Bash 50 passed (+2), PS 50 passed (+1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/linux/setup.sh | 4 ++ scripts/windows/setup.ps1 | 47 +++++++++---------- tests/test_setup_flags.sh | 80 ++++++++++++++++++++++++--------- tests/test_setup_flags_pwsh.ps1 | 17 +++++++ 4 files changed, 103 insertions(+), 45 deletions(-) diff --git a/scripts/linux/setup.sh b/scripts/linux/setup.sh index 39e8e98..c5a5b38 100755 --- a/scripts/linux/setup.sh +++ b/scripts/linux/setup.sh @@ -204,6 +204,10 @@ is_interactive() { if [[ $ARG_NON_INTERACTIVE_SET -eq 1 || $ARG_ONLY_SET -eq 1 || $ARG_SKIP_SET -eq 1 ]]; then return 1 fi + # --interactive + --selection-file: bypass CI/TTY detection so CI can test the menu path. + if [[ $ARG_INTERACTIVE_SET -eq 1 && $ARG_SELECTION_FILE_SET -eq 1 ]]; then + return 0 + fi if [[ "${SETUP_NON_INTERACTIVE:-}" == "1" || -n "${CI:-}" || -n "${GITHUB_ACTIONS:-}" ]]; then return 1 fi diff --git a/scripts/windows/setup.ps1 b/scripts/windows/setup.ps1 index 8caead0..6938588 100644 --- a/scripts/windows/setup.ps1 +++ b/scripts/windows/setup.ps1 @@ -208,9 +208,13 @@ function Test-ShouldShowMenu { param( [bool]$NonInteractiveRequested, [bool]$OnlySet, - [bool]$SkipSet + [bool]$SkipSet, + [bool]$InteractiveRequested, + [bool]$SelectionFileSet ) if ($NonInteractiveRequested -or $OnlySet -or $SkipSet) { return $false } + # -Interactive + -SelectionFile: bypass CI/TTY detection so CI can test the menu path. + if ($InteractiveRequested -and $SelectionFileSet) { return $true } if ($env:SETUP_NON_INTERACTIVE -eq '1' -or $env:CI -or $env:GITHUB_ACTIONS) { return $false } if ([Console]::IsInputRedirected -or -not [Environment]::UserInteractive) { return $false } if ($null -eq $Host.UI.RawUI) { return $false } @@ -221,49 +225,42 @@ function Test-ShouldShowMenu { $null = Test-ShouldShowMenu ` -NonInteractiveRequested $NonInteractive.IsPresent ` -OnlySet ($PSBoundParameters.ContainsKey('Only')) ` - -SkipSet ($PSBoundParameters.ContainsKey('Skip')) + -SkipSet ($PSBoundParameters.ContainsKey('Skip')) ` + -InteractiveRequested $Interactive.IsPresent ` + -SelectionFileSet ($PSBoundParameters.ContainsKey('SelectionFile')) # --------------------------------------------------------------------------- # Build FinalToolSet # --------------------------------------------------------------------------- $FinalTools = @() $Available = Get-AvailableTool -$SelectionNames = @() $UseSelectionFile = $PSBoundParameters.ContainsKey('SelectionFile') -and -not $PSBoundParameters.ContainsKey('Only') -and -not $PSBoundParameters.ContainsKey('Skip') +# Selection-file: validate, join names, route through the canonical -Only path. +$EffectiveOnly = '' +$UseOnlyPath = $false + if ($UseSelectionFile) { if ([string]::IsNullOrEmpty($SelectionFile) -or -not (Test-Path -LiteralPath $SelectionFile -PathType Leaf)) { Write-Err "Selection file not found: $SelectionFile" exit 1 } - $SelectionNames = @(Get-Content -LiteralPath $SelectionFile | Where-Object { $_ -ne '' }) -} - -if ($UseSelectionFile) { - if ($SelectionNames.Count -eq 0) { + $fileNames = @(Get-Content -LiteralPath $SelectionFile | Where-Object { $_ -ne '' }) + if ($fileNames.Count -eq 0) { Write-Err "Flag requires at least one tool name." exit 1 } - $names = $SelectionNames - foreach ($name in $names) { - if ($Available -notcontains $name) { - Write-Err "Unknown tool: $name" - Write-Err "Available tools: $($Available -join ', ')" - exit 1 - } - } - foreach ($tool in $DefaultTools) { - if ($names -contains $tool) { - $FinalTools += $tool - } - } - $optIn = @($names | Where-Object { $DefaultTools -notcontains $_ } | Sort-Object) - foreach ($t in $optIn) { $FinalTools += $t } - + $EffectiveOnly = $fileNames -join ',' + $UseOnlyPath = $true } elseif ($PSBoundParameters.ContainsKey('Only')) { - $names = Split-ToolList -ToolList $Only + $EffectiveOnly = $Only + $UseOnlyPath = $true +} + +if ($UseOnlyPath) { + $names = Split-ToolList -ToolList $EffectiveOnly foreach ($name in $names) { if ($Available -notcontains $name) { Write-Err "Unknown tool: $name" diff --git a/tests/test_setup_flags.sh b/tests/test_setup_flags.sh index 3d7b343..d9d76f7 100644 --- a/tests/test_setup_flags.sh +++ b/tests/test_setup_flags.sh @@ -591,62 +591,102 @@ interactive_fn="$(awk '/^is_interactive\(\)/,/^}/' "$LINUX_SETUP")" echo "" echo "--- T_menu_ci_skip ---" -if ( +_rc=127 +( eval "$interactive_fn" # shellcheck disable=SC2034 - ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 ARG_INTERACTIVE_SET=0 ARG_SELECTION_FILE_SET=0 # shellcheck disable=SC2034 CI=true GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' - ! is_interactive -); then + is_interactive +) && _rc=$? || _rc=$? +if [[ $_rc -eq 1 ]]; then pass "T_menu_ci_skip: CI suppresses interactive mode" else - fail "T_menu_ci_skip: CI did not suppress interactive mode" + fail "T_menu_ci_skip: CI did not suppress interactive mode (exit=$_rc)" fi echo "" echo "--- T_menu_tty_skip ---" -if ( +_rc=127 +( eval "$interactive_fn" # shellcheck disable=SC2034 - ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 ARG_INTERACTIVE_SET=0 ARG_SELECTION_FILE_SET=0 # shellcheck disable=SC2034 CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' - ! is_interactive -); then + is_interactive +) && _rc=$? || _rc=$? +if [[ $_rc -eq 1 ]]; then pass "T_menu_tty_skip: redirected test harness suppresses interactive mode" else - fail "T_menu_tty_skip: redirected test harness was treated as interactive" + fail "T_menu_tty_skip: redirected test harness was treated as interactive (exit=$_rc)" fi echo "" echo "--- T_menu_non_interactive_flag ---" -if ( +_rc=127 +( eval "$interactive_fn" # shellcheck disable=SC2034 - ARG_NON_INTERACTIVE_SET=1 ARG_ONLY_SET=0 ARG_SKIP_SET=0 + ARG_NON_INTERACTIVE_SET=1 ARG_ONLY_SET=0 ARG_SKIP_SET=0 ARG_INTERACTIVE_SET=0 ARG_SELECTION_FILE_SET=0 # shellcheck disable=SC2034 CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' - ! is_interactive -); then + is_interactive +) && _rc=$? || _rc=$? +if [[ $_rc -eq 1 ]]; then pass "T_menu_non_interactive_flag: explicit flag suppresses interactive mode" else - fail "T_menu_non_interactive_flag: explicit flag did not suppress interactive mode" + fail "T_menu_non_interactive_flag: explicit flag did not suppress interactive mode (exit=$_rc)" fi echo "" echo "--- T_menu_only_suppresses_guard ---" -if ( +_rc=127 +( eval "$interactive_fn" # shellcheck disable=SC2034 - ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=1 ARG_SKIP_SET=0 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=1 ARG_SKIP_SET=0 ARG_INTERACTIVE_SET=0 ARG_SELECTION_FILE_SET=0 # shellcheck disable=SC2034 CI='' GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' - ! is_interactive -); then + is_interactive +) && _rc=$? || _rc=$? +if [[ $_rc -eq 1 ]]; then pass "T_menu_only_suppresses_guard: --only suppresses interactive mode" else - fail "T_menu_only_suppresses_guard: --only did not suppress interactive mode" + fail "T_menu_only_suppresses_guard: --only did not suppress interactive mode (exit=$_rc)" +fi + +echo "" +echo "--- T_menu_skip_guards_127 ---" +# Mutation guard: proves exit 127 (missing function) is NOT exit 1. +# The old '! is_interactive' pattern could not distinguish them. +# [[ _rc -eq 1 ]] can -- this test fails if the guard is wrong. +_rc=127 +( + _this_function_does_not_exist_and_exits_127 +) && _rc=$? || _rc=$? +if [[ $_rc -eq 127 && $_rc -ne 1 ]]; then + pass "T_menu_skip_guards_127: exit-127 is distinct from exit-1 (exact-status guard valid)" +else + fail "T_menu_skip_guards_127: unexpected exit=$_rc from missing-function probe" +fi + +echo "" +echo "--- T_menu_selection_file_ci_bypass ---" +_rc=127 +( + eval "$interactive_fn" + # shellcheck disable=SC2034 + ARG_NON_INTERACTIVE_SET=0 ARG_ONLY_SET=0 ARG_SKIP_SET=0 ARG_INTERACTIVE_SET=1 ARG_SELECTION_FILE_SET=1 + # shellcheck disable=SC2034 + CI=true GITHUB_ACTIONS='' SETUP_NON_INTERACTIVE='' + is_interactive +) && _rc=$? || _rc=$? +if [[ $_rc -eq 0 ]]; then + pass "T_menu_selection_file_ci_bypass: --interactive + --selection-file is interactive under CI" +else + fail "T_menu_selection_file_ci_bypass: bypass failed under CI (exit=$_rc)" fi echo "" diff --git a/tests/test_setup_flags_pwsh.ps1 b/tests/test_setup_flags_pwsh.ps1 index c5a22ec..d891d80 100644 --- a/tests/test_setup_flags_pwsh.ps1 +++ b/tests/test_setup_flags_pwsh.ps1 @@ -652,6 +652,23 @@ Test-Scenario "T_menu_only_suppresses_guard_ps: -Only suppresses interactive mod } } +Test-Scenario "T_menu_selection_file_ci_bypass_ps: -Interactive + -SelectionFile is interactive under CI" { + $oldCi = $env:CI + try { + $env:CI = 'true' + $result = Test-ShouldShowMenu ` + -NonInteractiveRequested $false ` + -OnlySet $false ` + -SkipSet $false ` + -InteractiveRequested $true ` + -SelectionFileSet $true + if (-not $result) { + throw "-Interactive + -SelectionFile was not treated as interactive under CI" + } + } + finally { $env:CI = $oldCi } +} + Test-Scenario "T_noarg_noninteractive_compat_ps: CI no-arg run matches defaults" { Setup-Harness $oldCi = $env:CI