diff --git a/Resources/Server/1MB-start.sh b/Resources/Server/1MB-start.sh index 55f25f4..fcd9093 100755 --- a/Resources/Server/1MB-start.sh +++ b/Resources/Server/1MB-start.sh @@ -1,13 +1,13 @@ #!/usr/bin/env bash # @Filename: 1MB-start.sh -# @Version: 2.18.3, build 086 for Minecraft 26.2 (Java 25, 64bit) +# @Version: 2.19.0, build 088 # @Release: August 4th, 2026 -# @Description: Helps us start and fork a Minecraft 26.2 server session. +# @Description: Starts the adjacent Minecraft server launcher in a detached tmux session. # @Contact: I am @floris on Twitter, and mrfloris in MineCraft. # @Discord: @mrfloris on https://discord.gg/floris # @Install: chmod +x 1MB-start.sh -# @Syntax: ./1MB-start.sh [name] (see --help for commands) +# @Syntax: ./1MB-start.sh [command] [name] (see --help) # @URL: Latest source, wiki, & support: https://scripts.1moreblock.com/ ### CONFIGURATION @@ -33,6 +33,13 @@ _serverName="mcserver" _sibling="1MB-minecraft.sh" _startupCheckSeconds=2 # Check process liveness only; this is not Paper readiness. _debug=true # Set to false to minimize output. +_scriptName="1MB-start.sh" +_scriptVersion="2.19.0" +_scriptBuild="088" +_tmuxPath="" +_sleepPath="" +_launchArguments=() +_doctorFailures=0 ### FUNCTIONS AND CODE # @@ -123,32 +130,197 @@ function _showHelp { printf '%s\n' \ "Usage:" \ " $_programName [name]" \ + " $_programName --start [name]" \ " $_programName --status [name]" \ " $_programName --attach [name]" \ + " $_programName --dry-run [name]" \ + " $_programName --doctor" \ + " $_programName --list" \ + " $_programName --version" \ " $_programName --help" \ "" \ "Commands:" \ " (none) Start the adjacent $_sibling in detached tmux." \ + " --start [name] Explicit form of the default start command." \ " --status [name] Report whether the exact tmux session exists." \ " --attach [name] Attach to the exact tmux session." \ + " --dry-run [name] Validate and print the launch plan without running it." \ + " --doctor Run read-only launch-readiness diagnostics." \ + " --list List all tmux session names visible to the current OS user." \ + " --version Show this wrapper's version and build." \ " --help, -h Show this help." \ "" \ "The session name defaults to '$_serverName'." \ "Names use 1-32 lowercase ASCII letters, numbers, hyphens or underscores." \ - "The first character must be a letter or number." + "The first character must be a letter or number." \ + "Doctor and dry-run perform static checks only; neither starts the server." } -function _setServerName { +function _showVersion { + printf '%s %s, build %s\n' "$_scriptName" "$_scriptVersion" "$_scriptBuild" +} + +function _isValidServerName { local _requestedName="${1-}" local LC_ALL=C - if [ "${#_requestedName}" -gt 32 ] || ! [[ "$_requestedName" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then + [ "${#_requestedName}" -le 32 ] && [[ "$_requestedName" =~ ^[a-z0-9][a-z0-9_-]*$ ]] +} + +function _setServerName { + local _requestedName="${1-}" + + if ! _isValidServerName "$_requestedName"; then _output oops "Invalid session name. Use 1-32 lowercase ASCII letters, numbers, hyphens or underscores. The first character must be a letter or number." fi _serverName="$_requestedName" } +function _isValidSiblingPath { + local _path="${1:-}" + + [ ! -L "$_path" ] && [ -f "$_path" ] && [ -r "$_path" ] && [ -x "$_path" ] +} + +function _findExecutable { + local _name="${1:-}" + local _path="" + + _path=$(type -P "$_name" 2>/dev/null) || return 1 + [ -n "$_path" ] && [ -f "$_path" ] && [ -x "$_path" ] || return 1 + printf '%s\n' "$_path" +} + +function _requireTmux { + _tmuxPath=$(_findExecutable tmux) || _output oops "'tmux' is required but was not found. On macOS, install it with: brew install tmux" +} + +function _requireSleep { + _sleepPath=$(_findExecutable sleep) || _output oops "'sleep' is required for the startup liveness check but was not found." +} + +function _quoteValue { + printf '%q' "$1" +} + +function _setLaunchArguments { + _launchArguments=( + new-session -d -P -F '#{pane_id}' + -s "$_serverName" + -c "$_scriptDir" + "exec \"./$_sibling\"" + ';' + set-option -p -t "=$_serverName:" remain-on-exit on + ) +} + +function _showDryRun { + printf '%s\n' "Static launch preflight passed." + printf 'Session: %q\n' "$_serverName" + printf 'Server directory: %q\n' "$_scriptDir" + printf 'Launcher: %q\n' "$_siblingPath" + printf 'tmux executable: %q\n' "$_tmuxPath" + printf 'sleep executable: %q\n' "$_sleepPath" + printf 'Startup check: %s seconds (not performed)\n' "$_startupCheckSeconds" + printf 'Session availability: not checked (use --status %q)\n' "$_serverName" + printf 'Command:' + printf ' %q' "$_tmuxPath" "${_launchArguments[@]}" + printf '\n%s\n' "Dry run only: no tmux session was created and '$_sibling' was not executed." +} + +function _doctorOkay { + printf '[OK] %s\n' "$1" +} + +function _doctorFail { + _doctorFailures=$((_doctorFailures + 1)) + printf '[FAIL] %s\n' "$1" +} + +function _runDoctor { + local _commandName="" + local _commandPath="" + local _doctorScriptDir="" + local _doctorSiblingPath="" + local _tmuxCandidate="" + local _tmuxVersion="" + local _quotedValue="" + + _doctorFailures=0 + printf '%s doctor\n\n' "$_scriptName" + + if [ "${BASH_VERSINFO[0]}" -gt 3 ] || \ + { [ "${BASH_VERSINFO[0]}" -eq 3 ] && [ "${BASH_VERSINFO[1]}" -ge 2 ]; }; then + _doctorOkay "Bash $BASH_VERSION is supported (3.2 or newer)." + else + _doctorFail "Bash $BASH_VERSION is unsupported; Bash 3.2 or newer is required." + fi + + if [ "$EUID" -eq 0 ]; then + _doctorFail "Startup would refuse the root user or sudo." + else + _doctorOkay "The current user is not root." + fi + + if _isValidServerName "$_serverName"; then + _quotedValue=$(_quoteValue "$_serverName") + _doctorOkay "Configured default session name: $_quotedValue" + else + _quotedValue=$(_quoteValue "$_serverName") + _doctorFail "Configured default session name is invalid: $_quotedValue" + fi + + for _commandName in dirname readlink sleep; do + if _commandPath=$(_findExecutable "$_commandName"); then + _quotedValue=$(_quoteValue "$_commandPath") + _doctorOkay "Command '$_commandName': $_quotedValue" + else + _doctorFail "Required command '$_commandName' was not found as an executable file." + fi + done + + if _doctorScriptDir=$(_resolveScriptDirectory 2>/dev/null); then + _quotedValue=$(_quoteValue "$_doctorScriptDir") + _doctorOkay "Server directory: $_quotedValue" + _doctorSiblingPath="$_doctorScriptDir/$_sibling" + if _isValidSiblingPath "$_doctorSiblingPath"; then + _quotedValue=$(_quoteValue "$_doctorSiblingPath") + _doctorOkay "Adjacent launcher is regular, non-symlink, readable and executable: $_quotedValue" + else + _quotedValue=$(_quoteValue "$_doctorSiblingPath") + _doctorFail "Adjacent launcher is missing or unsafe: $_quotedValue" + fi + else + _quotedValue=$(_quoteValue "$0") + _doctorFail "Could not resolve the physical server directory containing $_quotedValue." + _doctorFail "Adjacent launcher could not be checked because the server directory was unresolved." + fi + + if _tmuxCandidate=$(_findExecutable tmux); then + if _tmuxVersion=$("$_tmuxCandidate" -V 2>&1); then + _tmuxCandidate=$(_quoteValue "$_tmuxCandidate") + _tmuxVersion=$(_quoteValue "$_tmuxVersion") + _doctorOkay "tmux: $_tmuxCandidate ($_tmuxVersion)" + else + _tmuxCandidate=$(_quoteValue "$_tmuxCandidate") + _tmuxVersion=$(_quoteValue "$_tmuxVersion") + _doctorFail "tmux exists but its version check failed: $_tmuxCandidate ($_tmuxVersion)" + fi + else + _doctorFail "tmux was not found as an executable file." + fi + + printf '\n%s\n' "Doctor scope: static checks only; no session was queried and '$_sibling' was not executed." + if [ "$_doctorFailures" -eq 0 ]; then + printf '%s\n' "Doctor result: PASS" + return 0 + fi + + printf 'Doctor result: FAIL (%s failed checks)\n' "$_doctorFailures" + return 1 +} + function _isPaneId { local _paneId="${1:-}" local _paneNumber="" @@ -175,15 +347,15 @@ function _verifyStartedPane { _output debug "Checking whether '$_sibling' remains active for $_startupCheckSeconds seconds ..." - if ! sleep "$_startupCheckSeconds"; then - if ! tmux set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1; then + if ! "$_sleepPath" "$_startupCheckSeconds"; then + if ! "$_tmuxPath" set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1; then _output oops "The $_startupCheckSeconds-second startup check was interrupted, and tmux could not restore normal close-on-exit behavior. Inspect the '$_serverName' session before retrying." fi _output oops "The $_startupCheckSeconds-second startup check was interrupted. The tmux session was not reported as started." fi - if ! _paneReport=$(tmux display-message -p -t "$_paneId" '#{pane_dead}|#{pane_dead_status}|#{pane_dead_signal}' 2>/dev/null); then - tmux set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1 || true + if ! _paneReport=$("$_tmuxPath" display-message -p -t "$_paneId" '#{pane_dead}|#{pane_dead_status}|#{pane_dead_signal}' 2>/dev/null); then + "$_tmuxPath" set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1 || true _output oops "'$_sibling' ended or its tmux pane disappeared during the $_startupCheckSeconds-second startup check. Run './$_sibling' directly from '$_scriptDir' to inspect its full output." fi @@ -194,16 +366,16 @@ function _verifyStartedPane { case "$_paneDead" in 0) - if ! tmux set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1; then + if ! "$_tmuxPath" set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1; then _output oops "'$_sibling' remained active, but tmux could not restore normal close-on-exit behavior for pane '$_paneId'. Inspect the '$_serverName' session before retrying." fi - if ! _paneDead=$(tmux display-message -p -t "$_paneId" '#{pane_dead}' 2>/dev/null); then + if ! _paneDead=$("$_tmuxPath" display-message -p -t "$_paneId" '#{pane_dead}' 2>/dev/null); then _output oops "'$_sibling' ended as the $_startupCheckSeconds-second startup check completed. The tmux session was not reported as started." fi if [ "$_paneDead" != 0 ]; then - tmux kill-pane -t "$_paneId" >/dev/null 2>&1 || true + "$_tmuxPath" kill-pane -t "$_paneId" >/dev/null 2>&1 || true _output oops "'$_sibling' ended as the $_startupCheckSeconds-second startup check completed. The tmux session was not reported as started." fi ;; @@ -220,11 +392,11 @@ function _verifyStartedPane { fi [ -z "$_exitDescription" ] && _exitDescription="unknown exit status" - tmux kill-pane -t "$_paneId" >/dev/null 2>&1 || true + "$_tmuxPath" kill-pane -t "$_paneId" >/dev/null 2>&1 || true _output oops "'$_sibling' exited during the $_startupCheckSeconds-second startup check ($_exitDescription). Run './$_sibling' directly from '$_scriptDir' to inspect its full output." ;; *) - tmux set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1 || true + "$_tmuxPath" set-option -p -t "$_paneId" remain-on-exit off >/dev/null 2>&1 || true _output oops "tmux returned an unexpected state for pane '$_paneId' during the startup check. The session was not reported as started." ;; esac @@ -239,6 +411,22 @@ case "${1:-}" in _showHelp exit 0 ;; +--version) + [ "$#" -eq 1 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." + _showVersion + exit 0 + ;; +--doctor) + [ "$#" -eq 1 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." + _action="doctor" + ;; +--start) + [ "$#" -le 2 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." + _action="start" + if [ "$#" -eq 2 ]; then + _nameArgument="$2" + fi + ;; --status) [ "$#" -le 2 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." _action="status" @@ -253,6 +441,17 @@ case "${1:-}" in _nameArgument="$2" fi ;; +--dry-run) + [ "$#" -le 2 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." + _action="dry-run" + if [ "$#" -eq 2 ]; then + _nameArgument="$2" + fi + ;; +--list) + [ "$#" -eq 1 ] || _output oops "Unexpected extra arguments. Run '$0 --help' for usage." + _action="list" + ;; --*) _output oops "Unknown option '$1'. Run '$0 --help' for usage." ;; @@ -264,26 +463,50 @@ case "${1:-}" in ;; esac -_setServerName "$_nameArgument" +if [ "$_action" = doctor ]; then + _runDoctor + exit $? +fi + +case "$_action" in +start|status|attach|dry-run) + _setServerName "$_nameArgument" + ;; +esac [ "$EUID" -eq 0 ] && _output oops "*!* This script should not be run using sudo, or as the root user!" -if [ "$_action" = start ]; then +case "$_action" in +start|dry-run) _scriptDir=$(_resolveScriptDirectory) || _output oops "Could not resolve the server directory containing '$0'." _siblingPath="$_scriptDir/$_sibling" - if [ -L "$_siblingPath" ] || [ ! -f "$_siblingPath" ] || [ ! -r "$_siblingPath" ] || [ ! -x "$_siblingPath" ]; then + if ! _isValidSiblingPath "$_siblingPath"; then _output oops "'$_sibling' must be a regular, non-symlink, readable and executable file beside this wrapper: '$_siblingPath'. Files elsewhere are not used. Correct the file, or download it from https://scripts.1moreblock.com/ " fi -fi + _requireSleep + ;; +esac -if ! type "tmux" >/dev/null 2>&1; then - _output oops "'tmux' is required but was not found. On macOS, install it with: brew install tmux" -fi +_requireTmux + +case "$_action" in +start|dry-run) + _setLaunchArguments + ;; +esac case "$_action" in +dry-run) + _showDryRun + exit 0 + ;; +list) + exec "$_tmuxPath" list-sessions -F '#{session_name}' + _output oops "Could not execute tmux list-sessions." + ;; status) - if tmux has-session -t "=$_serverName" 2>/dev/null; then + if "$_tmuxPath" has-session -t "=$_serverName" 2>/dev/null; then _output "tmux session '$_serverName' is running." exit 0 fi @@ -292,12 +515,12 @@ status) exit 1 ;; attach) - if ! tmux has-session -t "=$_serverName" 2>/dev/null; then + if ! "$_tmuxPath" has-session -t "=$_serverName" 2>/dev/null; then _output oops "No tmux session named '$_serverName' is running." fi _output debug "Attaching to tmux session '$_serverName' ..." - exec tmux attach-session -t "=$_serverName" + exec "$_tmuxPath" attach-session -t "=$_serverName" _output oops "Could not execute tmux attach-session for '$_serverName'." ;; esac @@ -308,21 +531,21 @@ _output debug "Using server directory: $_scriptDir" _output debug "Found 'tmux', attempting to start '$_sibling' in a detached tmux session ..." # Deliberately launch only ./1MB-minecraft.sh from the wrapper's own directory. _paneId="" -if ! _paneId=$(tmux new-session -d -P -F '#{pane_id}' -s "$_serverName" -c "$_scriptDir" "exec \"./$_sibling\"" \; set-option -p -t "=$_serverName:" remain-on-exit on); then +if ! _paneId=$("$_tmuxPath" "${_launchArguments[@]}"); then if _isPaneId "$_paneId"; then - tmux kill-pane -t "$_paneId" >/dev/null 2>&1 || true + "$_tmuxPath" kill-pane -t "$_paneId" >/dev/null 2>&1 || true fi _output oops "Could not create the '$_serverName' tmux session and prepare its startup check. Review the tmux error above and check with 'tmux ls'." fi if ! _isPaneId "$_paneId"; then - tmux kill-session -t "=$_serverName" >/dev/null 2>&1 || true + "$_tmuxPath" kill-session -t "=$_serverName" >/dev/null 2>&1 || true _output oops "tmux created the '$_serverName' session but did not return a valid pane identifier, so the new session was closed." fi _verifyStartedPane "$_paneId" -[[ "$_debug" == true ]] && tmux ls; _output debug "To re-attach: tmux attach -t $_serverName" +[[ "$_debug" == true ]] && "$_tmuxPath" ls; _output debug "To re-attach: tmux attach -t $_serverName" _output debug "tmux session started." diff --git a/Resources/Server/1mb-start-todo.md b/Resources/Server/1mb-start-todo.md index 54fe8ec..9673dbf 100644 --- a/Resources/Server/1mb-start-todo.md +++ b/Resources/Server/1mb-start-todo.md @@ -15,7 +15,7 @@ as deliberate follow-up changes. The baseline records the proven production version; it is not a claim that the script is free of edge-case defects. Follow-up work continues with version -`2.18.3`, build `086`. +`2.19.0`, build `088`. ## Completed in 2.17.0 build 073 @@ -152,6 +152,34 @@ scoped fix. The file's missing final newline was normalized mechanically. - [x] Added a path-filtered GitHub Actions workflow that runs the complete suite with ShellCheck on current macOS and Ubuntu hosted runners. +## Completed in 2.18.4 build 087 + +- [x] Removed the Minecraft, Paper, Java and architecture versions from the + wrapper header. The metadata now identifies only this wrapper's own version, + build and tmux-launcher purpose; runtime compatibility remains the concern of + `1MB-minecraft.sh`. + +## Completed in 2.19.0 build 088 + +- [x] Added dependency-free `--version` output for the wrapper's own version + and build. +- [x] Added `--doctor`, which aggregates read-only Bash, user, session-name, + command, directory, adjacent-launcher and tmux-version checks without + querying a tmux session or executing the launcher. +- [x] Added `--start [name]` as an explicit alias of the existing startup path; + both forms share the same validation, atomic tmux launch and liveness probe. +- [x] Added `--list`, explicitly scoped to all tmux session names visible to + the current OS user, with tmux's own output and exit status preserved. +- [x] Added `--dry-run [name]`, which performs static launch preflight and + safely renders the exact shared tmux argument vector without invoking tmux, + sleeping, querying session availability, or executing `1MB-minecraft.sh`. +- [x] Resolve and require both tmux and `sleep` before startup side effects; + real startup and dry-run use the resolved executable paths, so a missing + liveness-probe dependency cannot fail only after creating a session. +- [x] Extended the fake-tmux suite to cover command output, argument arity, + dependency isolation, exit statuses and the new read-only/no-side-effect + guarantees on both Bash 3.2 and current Bash. + ## Critical - [x] Stop immediately when tmux session creation fails; never send startup @@ -214,9 +242,9 @@ scoped fix. The file's missing final newline was normalized mechanically. - [x] Remove the obsolete Screen and Ubuntu dependency hints. Completed in `2.17.1`, build `074`; the missing-tmux error gives the requested macOS Homebrew command only. -- [ ] Make the wrapper header independent of Paper and Java versions, because +- [x] Make the wrapper header independent of Paper and Java versions, because RAM, JVM selection, Java version, and Paper arguments belong in - `1MB-minecraft.sh`. + `1MB-minecraft.sh`. Completed in `2.18.4`, build `087`. - [x] Add the missing final newline. Completed mechanically in `2.17.0`, build `073`; broader formatting remains deferred. @@ -228,8 +256,9 @@ scoped fix. The file's missing final newline was normalized mechanically. - [ ] Introduce a `main "$@"` function and explicit checked error paths. Review all helper return statuses before considering `set -e`; `set -u` and `set -o pipefail` can then be evaluated safely. -- [x] Add small `--help`, `--status`, and `--attach` commands. Completed in - `2.17.9`, build `082`; `--version` was intentionally not added. +- [x] Add focused convenience commands. `--help`, `--status` and `--attach` + were completed in `2.17.9`, build `082`; `--version`, `--doctor`, `--start`, + `--list` and `--dry-run` were completed in `2.19.0`, build `088`. - [x] Add repeatable checks with `bash -n`, ShellCheck, and disposable fake or isolated tmux sessions for success, duplicate-name, missing-sibling, and missing-dependency paths. Completed in `2.18.3`, build `086`, with committed @@ -259,5 +288,6 @@ scoped fix. The file's missing final newline was normalized mechanically. 1. [x] Session names use a documented safe set: 1-32 lowercase ASCII letters, numbers, hyphens or underscores, beginning with a letter or number. Completed in `2.18.2`, build `085`. -2. Confirm which quality-of-life commands, if any, belong in this intentionally - small wrapper. +2. [x] Add the approved quality-of-life commands: `--version`, `--doctor`, + `--start`, `--list` and `--dry-run`. Completed in `2.19.0`, build `088`, + with action-specific validation and fake-tmux coverage. diff --git a/Resources/Server/tests/1MB-start/README.md b/Resources/Server/tests/1MB-start/README.md index 7ff28a6..565fe56 100644 --- a/Resources/Server/tests/1MB-start/README.md +++ b/Resources/Server/tests/1MB-start/README.md @@ -42,9 +42,18 @@ Resources/Server/tests/1MB-start/run-tests.sh lint The behavior suite covers healthy startup, exact tmux targets, duplicate sessions, child exit statuses and signals, probe-boundary failures, `remain-on-exit` restoration, missing or invalid siblings, missing tmux, -status and attach behavior, session-name validation, argument validation, and -output return/stream behavior. The fake `sleep` makes the two-second liveness -probe complete immediately. +missing preflight `sleep`, status and attach behavior, session-name validation, +argument validation, and output return/stream behavior. It also verifies +dependency-free `--version`, +aggregated read-only `--doctor` checks, exact `--start` aliasing, current-user +`--list` output/status, and a `--dry-run` preview that invokes neither tmux nor +sleep. The fake `sleep` makes the two-second liveness probe complete +immediately. + +The fake tmux supports read-only `-V` and `list-sessions` scenarios in addition +to the startup lifecycle. Tests assert that doctor never contacts a tmux +session and that dry-run makes no tmux call at all, so these conveniences can +also be used when reviewing future wrapper changes. Tests must not be run with `sudo`: the production wrapper deliberately refuses to run as root. When a test fails, its disposable state and tmux call log are diff --git a/Resources/Server/tests/1MB-start/fake-bin/tmux b/Resources/Server/tests/1MB-start/fake-bin/tmux index 59fa74f..df1f3f4 100755 --- a/Resources/Server/tests/1MB-start/fake-bin/tmux +++ b/Resources/Server/tests/1MB-start/fake-bin/tmux @@ -23,6 +23,18 @@ done printf '\n' >>"$_logFile" case "$_command" in +-V) + case "$_scenario" in + doctor-version-failure) + printf '%s\n' "fake tmux: version query failed" >&2 + exit 17 + ;; + *) + printf '%s\n' 'tmux 3.5a-test' + exit 0 + ;; + esac + ;; new-session) case "$_scenario" in duplicate) @@ -61,6 +73,22 @@ attach-session) *) exit 1 ;; esac ;; +list-sessions) + case "$_scenario" in + list-sessions-empty) + printf '%s\n' "no server running on test socket" >&2 + exit 1 + ;; + list-sessions-failure) + printf '%s\n' "fake tmux: list query failed" >&2 + exit 17 + ;; + *) + printf '%s\n' mcserver maintenance_session + exit 0 + ;; + esac + ;; display-message) _format="" for _argument in "$@"; do diff --git a/Resources/Server/tests/1MB-start/run-tests.sh b/Resources/Server/tests/1MB-start/run-tests.sh index 49e1d7b..0ae989d 100755 --- a/Resources/Server/tests/1MB-start/run-tests.sh +++ b/Resources/Server/tests/1MB-start/run-tests.sh @@ -27,6 +27,7 @@ _caseWrapper="" _stdoutFile="" _stderrFile="" _runStatus=0 +_siblingMarker="" function _usage { printf '%s\n' \ @@ -200,12 +201,20 @@ function _runWrapper { local _scenario="$1" shift + _runWrapperWithPath "$_scenario" "$_suiteDir/fake-bin:$_originalPath" "$@" +} + +function _runWrapperWithPath { + local _scenario="$1" + local _runPath="$2" + shift 2 + : >"$_stdoutFile" : >"$_stderrFile" FAKE_TMUX_SCENARIO="$_scenario" \ FAKE_TMUX_STATE_DIR="$_caseStateDir" \ NO_COLOR=1 \ - PATH="$_suiteDir/fake-bin:$_originalPath" \ + PATH="$_runPath" \ TMUX='' \ TMUX_TMPDIR="$_caseStateDir/tmux-socket" \ "$_bashUnderTest" "$_caseWrapper" "$@" >"$_stdoutFile" 2>"$_stderrFile" @@ -229,14 +238,22 @@ function _expectContains { local _file="$1" local _text="$2" local _description="$3" - grep -Fq "$_text" "$_file" || _problem "missing $_description: $_text" + grep -Fq -- "$_text" "$_file" || _problem "missing $_description: $_text" +} + +function _expectExactLine { + local _file="$1" + local _line="$2" + local _description="$3" + + grep -Fqx -- "$_line" "$_file" || _problem "missing exact $_description: $_line" } function _expectNotContains { local _file="$1" local _text="$2" local _description="$3" - if grep -Fq "$_text" "$_file"; then + if grep -Fq -- "$_text" "$_file"; then _problem "unexpected $_description: $_text" fi } @@ -247,13 +264,61 @@ function _expectNoTmuxCall { fi } +function _expectNoSleepCall { + if [ -s "$_caseStateDir/sleep.log" ]; then + _problem "sleep was called unexpectedly" + fi +} + +function _setFixtureDefaultName { + local _name="$1" + local _updatedWrapper="$_caseDir/updated-1MB-start.sh" + + sed "s/^_serverName=.*/_serverName=\"$_name\"/" "$_caseWrapper" >"$_updatedWrapper" || exit 2 + mv "$_updatedWrapper" "$_caseWrapper" + chmod 0755 "$_caseWrapper" +} + +function _makeToolPathWithoutSleep { + local _path="$1" + local _tool="" + local _toolSource="" + + mkdir -p "$_path" + for _tool in dirname readlink; do + _toolSource=$(PATH="$_originalPath" command -v "$_tool") || exit 2 + ln -s "$_toolSource" "$_path/$_tool" + done + ln -s "$_fakeTmux" "$_path/tmux" +} + +function _armSiblingMarker { + _siblingMarker="$_caseDir/sibling-executed" + printf '#!/usr/bin/env bash\nprintf "executed\\n" > %q\n' "$_siblingMarker" >"$_caseServerDir/1MB-minecraft.sh" + chmod 0755 "$_caseServerDir/1MB-minecraft.sh" +} + +function _expectSiblingNotExecuted { + if [ -e "$_siblingMarker" ]; then + _problem "the sibling launcher was executed unexpectedly" + fi +} + function _expectLogLine { local _line="$1" - if ! grep -Fqx "$_line" "$_caseStateDir/tmux.log" 2>/dev/null; then + if ! grep -Fqx -- "$_line" "$_caseStateDir/tmux.log" 2>/dev/null; then _problem "missing exact tmux call: $_line" fi } +function _expectOnlyTmuxLogLine { + local _line="$1" + local _expected="$_caseDir/expected-only-tmux.log" + + printf '%s\n' "$_line" >"$_expected" + _expectFilesEqual "$_expected" "$_caseStateDir/tmux.log" "tmux call log" +} + function _expectNoLogCommand { local _command="$1" if grep -q "^${_command}" "$_caseStateDir/tmux.log" 2>/dev/null; then @@ -267,6 +332,19 @@ function _setExpectedLaunch { "$_name" "$_caseServerDir" "$_name" } +function _writeExpectedHealthyTmuxLog { + local _name="$1" + local _file="$2" + + _setExpectedLaunch "$_name" + printf '%s\n' \ + "$_expectedLaunch" \ + $'display-message\t-p\t-t\t%42\t#{pane_dead}|#{pane_dead_status}|#{pane_dead_signal}' \ + $'set-option\t-p\t-t\t%42\tremain-on-exit\toff' \ + $'display-message\t-p\t-t\t%42\t#{pane_dead}' \ + 'ls' >"$_file" +} + function _expectFilesEqual { local _expected="$1" local _actual="$2" @@ -290,20 +368,116 @@ function _testHealthyStart { _expectContains "$_stdoutFile" "tmux session started." "success message" _escape=$(printf '\033') _expectNotContains "$_stdoutFile" "$_escape" "ANSI escape" - _setExpectedLaunch mcserver-2 _expectedLog="$_caseDir/expected-tmux.log" - printf '%s\n' \ - "$_expectedLaunch" \ - $'display-message\t-p\t-t\t%42\t#{pane_dead}|#{pane_dead_status}|#{pane_dead_signal}' \ - $'set-option\t-p\t-t\t%42\tremain-on-exit\toff' \ - $'display-message\t-p\t-t\t%42\t#{pane_dead}' \ - 'ls' >"$_expectedLog" + _writeExpectedHealthyTmuxLog mcserver-2 "$_expectedLog" _expectFilesEqual "$_expectedLog" "$_caseStateDir/tmux.log" "tmux call order" printf '%s\n' '2' >"$_caseDir/expected-sleep.log" _expectFilesEqual "$_caseDir/expected-sleep.log" "$_caseStateDir/sleep.log" "sleep call" _finishCase } +function _testExplicitStart { + local _expectedLog="" + + _beginCase "explicit --start is the exact startup alias" + + _newFixture executable + _runWrapper healthy --start mcserver-2 + _expectStatus 0 + _expectEmpty "$_stderrFile" "stderr" + _expectedLog="$_caseDir/expected-tmux.log" + _writeExpectedHealthyTmuxLog mcserver-2 "$_expectedLog" + _expectFilesEqual "$_expectedLog" "$_caseStateDir/tmux.log" "explicit-start tmux call order" + _expectContains "$_stdoutFile" "tmux session started." "success message" + printf '%s\n' '2' >"$_caseDir/expected-sleep.log" + _expectFilesEqual "$_caseDir/expected-sleep.log" "$_caseStateDir/sleep.log" "sleep call" + + _newFixture executable + _runWrapper healthy --start + _expectStatus 0 + _expectedLog="$_caseDir/expected-tmux.log" + _writeExpectedHealthyTmuxLog mcserver "$_expectedLog" + _expectFilesEqual "$_expectedLog" "$_caseStateDir/tmux.log" "default explicit-start tmux call order" + printf '%s\n' '2' >"$_caseDir/expected-sleep.log" + _expectFilesEqual "$_caseDir/expected-sleep.log" "$_caseStateDir/sleep.log" "default explicit-start sleep call" + _expectContains "$_stdoutFile" "tmux attach -t mcserver" "default attach guidance" + _finishCase +} + +function _testVersionCommand { + local _emptyPath="" + local _headerVersion="" + + _beginCase "version is exact and independent of runtime dependencies" + _newFixture missing + _setFixtureDefaultName "Invalid Name" + _emptyPath="$_caseDir/empty-path" + mkdir -p "$_emptyPath" + _runWrapperWithPath healthy "$_emptyPath" --version + _expectStatus 0 + _headerVersion=$(sed -n 's/^# @Version: //p' "$_target") + printf '1MB-start.sh %s\n' "$_headerVersion" >"$_caseDir/expected-version" + _expectFilesEqual "$_caseDir/expected-version" "$_stdoutFile" "version output" + _expectEmpty "$_stderrFile" "stderr" + _expectNotContains "$_stdoutFile" "Paper" "runtime product" + _expectNotContains "$_stdoutFile" "Java" "runtime version" + _expectNotContains "$_stdoutFile" "Minecraft" "game version" + _expectNoTmuxCall + _expectNoSleepCall + _finishCase +} + +function _testDoctorCommand { + local _emptyPath="" + + _beginCase "doctor aggregates static checks without touching a session" + + _newFixture executable + _armSiblingMarker + _runWrapper healthy --doctor + _expectStatus 0 + _expectEmpty "$_stderrFile" "stderr" + _expectContains "$_stdoutFile" "Configured default session name: mcserver" "session-name check" + _expectContains "$_stdoutFile" "3.5a-test" "tmux version" + _expectContains "$_stdoutFile" "Doctor result: PASS" "healthy result" + _expectOnlyTmuxLogLine '-V' + _expectNoSleepCall + _expectSiblingNotExecuted + + _newFixture symlink + _runWrapper doctor-version-failure --doctor + _expectStatus 1 + _expectContains "$_stdoutFile" "Adjacent launcher is missing or unsafe" "unsafe-launcher failure" + _expectContains "$_stdoutFile" "version check failed" "tmux-version failure" + _expectContains "$_stdoutFile" "Doctor result: FAIL (2 failed checks)" "aggregated result" + _expectOnlyTmuxLogLine '-V' + _expectNoSleepCall + + _newFixture executable + _setFixtureDefaultName "Invalid Name" + _runWrapper healthy --doctor + _expectStatus 1 + _expectContains "$_stdoutFile" 'Configured default session name is invalid: Invalid\ Name' "invalid-default failure" + _expectContains "$_stdoutFile" "Doctor result: FAIL (1 failed checks)" "invalid-default result" + _expectOnlyTmuxLogLine '-V' + + _newFixture executable + _emptyPath="$_caseDir/empty-path" + mkdir -p "$_emptyPath" + _runWrapperWithPath healthy "$_emptyPath" --doctor + _expectStatus 1 + _expectContains "$_stdoutFile" "Required command 'dirname' was not found" "dirname failure" + _expectContains "$_stdoutFile" "Required command 'readlink' was not found" "readlink failure" + _expectContains "$_stdoutFile" "Required command 'sleep' was not found" "sleep failure" + _expectContains "$_stdoutFile" "Could not resolve the physical server directory" "directory failure" + _expectContains "$_stdoutFile" "tmux was not found" "tmux failure" + _expectContains "$_stdoutFile" "Doctor result: FAIL" "missing-tools result" + _expectEmpty "$_stderrFile" "structured doctor stderr" + _expectNoTmuxCall + _expectNoSleepCall + _finishCase +} + function _testConfiguredDefaultName { _beginCase "omitted name validates and preserves the configured default" @@ -489,6 +663,37 @@ function _testAttachCommands { _finishCase } +function _testListCommand { + _beginCase "list shows all current-user tmux sessions and preserves status" + + _newFixture missing + _setFixtureDefaultName "Invalid Name" + _runWrapper list-sessions-present --list + _expectStatus 0 + _expectEmpty "$_stderrFile" "stderr" + printf '%s\n' mcserver maintenance_session >"$_caseDir/expected-list" + _expectFilesEqual "$_caseDir/expected-list" "$_stdoutFile" "listed sessions" + _expectOnlyTmuxLogLine $'list-sessions\t-F\t#{session_name}' + _expectNoSleepCall + + _newFixture missing + _runWrapper list-sessions-empty --list + _expectStatus 1 + _expectEmpty "$_stdoutFile" "empty-list stdout" + _expectContains "$_stderrFile" "no server running on test socket" "empty-list diagnostic" + _expectOnlyTmuxLogLine $'list-sessions\t-F\t#{session_name}' + _expectNoSleepCall + + _newFixture missing + _runWrapper list-sessions-failure --list + _expectStatus 17 + _expectEmpty "$_stdoutFile" "failed-list stdout" + _expectContains "$_stderrFile" "list query failed" "list failure" + _expectOnlyTmuxLogLine $'list-sessions\t-F\t#{session_name}' + _expectNoSleepCall + _finishCase +} + function _testSiblingValidation { local _mode="" @@ -503,6 +708,93 @@ function _testSiblingValidation { _finishCase } +function _testDryRunCommand { + local _expectedCommand="" + local _tool="" + local _toolPath="" + local _toolSource="" + + _beginCase "dry-run previews the exact launch without side effects" + + _newFixture executable + _armSiblingMarker + _runWrapper healthy --dry-run mcserver-2 + _expectStatus 0 + _expectEmpty "$_stderrFile" "stderr" + _expectContains "$_stdoutFile" "Static launch preflight passed." "preflight result" + _expectContains "$_stdoutFile" "Session: mcserver-2" "session" + _expectContains "$_stdoutFile" "server\\ directory\\ with\\ spaces" "escaped server directory" + _expectContains "$_stdoutFile" "Startup check: 2 seconds (not performed)" "unperformed probe" + _expectContains "$_stdoutFile" "sleep executable:" "resolved sleep" + _expectContains "$_stdoutFile" "Session availability: not checked" "unqueried session" + # The Bash under test renders the same argument vector format as dry-run. + # shellcheck disable=SC2016 + _expectedCommand=$("$_bashUnderTest" -c 'printf "Command:"; for argument in "$@"; do printf " %q" "$argument"; done; printf "\n"' \ + _ "$_fakeTmux" new-session -d -P -F '#{pane_id}' -s mcserver-2 \ + -c "$_caseServerDir" 'exec "./1MB-minecraft.sh"' ';' \ + set-option -p -t '=mcserver-2:' remain-on-exit on) + _expectExactLine "$_stdoutFile" "$_expectedCommand" "launch command preview" + _expectContains "$_stdoutFile" "no tmux session was created" "side-effect notice" + _expectNotContains "$_stdoutFile" "tmux session started." "startup success" + _expectNoTmuxCall + _expectNoSleepCall + _expectSiblingNotExecuted + + _newFixture executable + _runWrapper healthy --dry-run + _expectStatus 0 + _expectContains "$_stdoutFile" "Session: mcserver" "default session" + _expectContains "$_stdoutFile" "--status mcserver" "default status guidance" + _expectNoTmuxCall + _expectNoSleepCall + + _newFixture missing + _runWrapper healthy --dry-run mcserver + _expectStatus 1 + _expectContains "$_stderrFile" "must be a regular, non-symlink, readable and executable file" "missing sibling" + _expectNoTmuxCall + _expectNoSleepCall + + _newFixture executable + _toolPath="$_caseDir/path-without-tmux" + mkdir -p "$_toolPath" + for _tool in dirname readlink sleep; do + _toolSource=$(PATH="$_originalPath" command -v "$_tool") || exit 2 + ln -s "$_toolSource" "$_toolPath/$_tool" + done + _runWrapperWithPath healthy "$_toolPath" --dry-run mcserver + _expectStatus 1 + _expectContains "$_stderrFile" "'tmux' is required but was not found" "missing tmux" + _expectNoTmuxCall + _expectNoSleepCall + _finishCase +} + +function _testMissingSleepPreflight { + local _toolPath="" + + _beginCase "missing sleep fails start and dry-run before tmux" + + _newFixture executable + _toolPath="$_caseDir/path-without-sleep" + _makeToolPathWithoutSleep "$_toolPath" + _runWrapperWithPath healthy "$_toolPath" --dry-run mcserver + _expectStatus 1 + _expectContains "$_stderrFile" "'sleep' is required" "dry-run sleep dependency" + _expectNoTmuxCall + _expectNoSleepCall + + _newFixture executable + _toolPath="$_caseDir/path-without-sleep" + _makeToolPathWithoutSleep "$_toolPath" + _runWrapperWithPath healthy "$_toolPath" --start mcserver + _expectStatus 1 + _expectContains "$_stderrFile" "'sleep' is required" "startup sleep dependency" + _expectNoTmuxCall + _expectNoSleepCall + _finishCase +} + function _testMissingTmux { local _emptyPath="" @@ -555,6 +847,12 @@ function _testArgumentsAndHelp { _runWrapper healthy --help _expectStatus 0 _expectContains "$_stdoutFile" "Names use 1-32 lowercase ASCII letters" "name help" + _expectContains "$_stdoutFile" "--version" "version help" + _expectContains "$_stdoutFile" "--doctor" "doctor help" + _expectContains "$_stdoutFile" "--start [name]" "start help" + _expectContains "$_stdoutFile" "--list" "list help" + _expectContains "$_stdoutFile" "--dry-run [name]" "dry-run help" + _expectContains "$_stdoutFile" "all tmux session names visible to the current OS user" "list scope help" _expectNoTmuxCall _newFixture executable @@ -563,6 +861,24 @@ function _testArgumentsAndHelp { _expectContains "$_stderrFile" "Unexpected extra arguments." "help arity error" _expectNoTmuxCall + _newFixture executable + _runWrapper healthy --version extra + _expectStatus 1 + _expectContains "$_stderrFile" "Unexpected extra arguments." "version arity error" + _expectNoTmuxCall + + _newFixture executable + _runWrapper healthy --doctor extra + _expectStatus 1 + _expectContains "$_stderrFile" "Unexpected extra arguments." "doctor arity error" + _expectNoTmuxCall + + _newFixture executable + _runWrapper healthy --start mcserver extra + _expectStatus 1 + _expectContains "$_stderrFile" "Unexpected extra arguments." "start arity error" + _expectNoTmuxCall + _newFixture executable _runWrapper healthy --status mcserver extra _expectStatus 1 @@ -575,12 +891,36 @@ function _testArgumentsAndHelp { _expectContains "$_stderrFile" "Unexpected extra arguments." "attach arity error" _expectNoTmuxCall + _newFixture executable + _runWrapper healthy --dry-run mcserver extra + _expectStatus 1 + _expectContains "$_stderrFile" "Unexpected extra arguments." "dry-run arity error" + _expectNoTmuxCall + + _newFixture executable + _runWrapper healthy --list extra + _expectStatus 1 + _expectContains "$_stderrFile" "Unexpected extra arguments." "list arity error" + _expectNoTmuxCall + _newFixture executable _runWrapper healthy mcserver extra _expectStatus 1 _expectContains "$_stderrFile" "Unexpected extra arguments." "startup arity error" _expectNoTmuxCall + _newFixture executable + _runWrapper healthy --start --help + _expectStatus 1 + _expectContains "$_stderrFile" "Invalid session name." "option-like start name" + _expectNoTmuxCall + + _newFixture executable + _runWrapper healthy --dry-run "Invalid Name" + _expectStatus 1 + _expectContains "$_stderrFile" "Invalid session name." "invalid dry-run name" + _expectNoTmuxCall + _newFixture executable _runWrapper healthy --unknown _expectStatus 1 @@ -640,6 +980,9 @@ function _runBehaviorForBash { printf '\nBehavior tests with %s\n' "$_version" _testHealthyStart + _testExplicitStart + _testVersionCommand + _testDoctorCommand _testConfiguredDefaultName _testListFailureIsNonfatal _testDuplicateSession @@ -659,7 +1002,10 @@ function _runBehaviorForBash { _testSleepFailure sleep-failure-restore-failure "could not restore normal close-on-exit behavior" "interrupted probe reports restore failure" _testStatusCommands _testAttachCommands + _testListCommand _testSiblingValidation + _testDryRunCommand + _testMissingSleepPreflight _testMissingTmux _testNames _testArgumentsAndHelp