test: create vitest based integration suite

This commit is contained in:
Pujit Mehrotra
2025-12-10 14:15:19 -05:00
parent 22bb548833
commit e5abbcbf90
19 changed files with 389 additions and 1450 deletions

View File

@@ -10,8 +10,6 @@
"dev": "pnpm -r dev",
"unraid:deploy": "pnpm -r unraid:deploy",
"test": "pnpm -r test",
"test:bats": "pnpm --filter @unraid/bats-tests test",
"test:bats:integration": "pnpm --filter @unraid/bats-tests test:integration",
"test:system": "pnpm --filter @unraid/system-integration-tests test",
"test:watch": "pnpm -r --parallel test:watch",
"lint": "pnpm -r lint",
@@ -75,6 +73,9 @@
],
"unraid-ui/**/*.{js,ts,tsx,vue}": [
"pnpm --filter @unraid/ui lint:fix"
],
"tests/system-integration/**/*.ts": [
"pnpm --filter @unraid/system-integration-tests lint:fix"
]
},
"packageManager": "pnpm@10.15.0"

53
pnpm-lock.yaml generated
View File

@@ -853,26 +853,29 @@ importers:
specifier: 3.2.4
version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
tests/bats:
devDependencies:
bats:
specifier: ^1.13.0
version: 1.13.0
bats-assert:
specifier: ^2.2.4
version: 2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0)
bats-support:
specifier: ^0.3.0
version: 0.3.0(bats@1.13.0)
tests/system-integration:
devDependencies:
'@eslint/js':
specifier: ^9.34.0
version: 9.34.0
eslint:
specifier: ^9.34.0
version: 9.34.0(jiti@2.5.1)
execa:
specifier: ^9.6.0
version: 9.6.0
jiti:
specifier: ^2.5.1
version: 2.5.1
prettier:
specifier: ^3.6.2
version: 3.6.2
typescript:
specifier: ^5.9.2
version: 5.9.2
typescript-eslint:
specifier: ^8.41.0
version: 8.41.0(eslint@9.34.0(jiti@2.5.1))(typescript@5.9.2)
vitest:
specifier: ^3.2.4
version: 3.2.4(@types/node@22.18.0)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.5.1)(jsdom@26.1.0)(lightningcss@1.30.1)(stylus@0.57.0)(terser@5.43.1)(tsx@4.20.5)(yaml@2.8.1)
@@ -6050,21 +6053,6 @@ packages:
resolution: {integrity: sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg==}
engines: {node: '>= 0.8'}
bats-assert@2.2.4:
resolution: {integrity: sha512-EcaY4Z+Tbz1c7pnC1SrVSq0epr7tLwFpz6qt7KUW9K8uSw8V12DTfH9d2HxZWvBEATaCuMsZ7KoZMFiSQPRoXw==}
peerDependencies:
bats: 0.4 || ^1
bats-support: ^0.3
bats-support@0.3.0:
resolution: {integrity: sha512-z+2WzXbI4OZgLnynydqH8GpI3+DcOtepO66PlK47SfEzTkiuV9hxn9eIQX+uLVFbt2Oqoc7Ky3TJ/N83lqD+cg==}
peerDependencies:
bats: 0.4 || ^1
bats@1.13.0:
resolution: {integrity: sha512-giSYKGTOcPZyJDbfbTtzAedLcNWdjCLbXYU3/MwPnjyvDXzu6Dgw8d2M+8jHhZXSmsCMSQqCp+YBsJ603UO4vQ==}
hasBin: true
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
@@ -17943,17 +17931,6 @@ snapshots:
dependencies:
safe-buffer: 5.1.2
bats-assert@2.2.4(bats-support@0.3.0(bats@1.13.0))(bats@1.13.0):
dependencies:
bats: 1.13.0
bats-support: 0.3.0(bats@1.13.0)
bats-support@0.3.0(bats@1.13.0):
dependencies:
bats: 1.13.0
bats@1.13.0: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5

View File

@@ -5,5 +5,4 @@ packages:
- "./unraid-ui"
- "./web"
- "./packages/*"
- "./tests/bats"
- "./tests/system-integration"

View File

@@ -1,115 +0,0 @@
# BATS Integration Tests
This directory contains BATS (Bash Automated Testing System) integration tests for the unraid-api.
## Prerequisites
- BATS installed (via `pnpm install`)
- SSH access to target Unraid server as root
- unraid-api deployed on target server
## Running Tests
```bash
# Run all tests
SERVER=tower pnpm test:bats
# Run integration tests only
SERVER=tower pnpm test:bats:integration
# Run a specific test file
SERVER=tower pnpm exec bats tests/bats/integration/singleton.bats
# Run tests matching a pattern
SERVER=tower pnpm exec bats tests/bats/ --filter "start:"
```
## Adding New Tests
1. Create a new `.bats` file in the appropriate directory:
- `integration/` - Tests requiring remote server
2. Load the common helpers in setup:
```bash
setup_file() {
load '../test_helper/common'
}
setup() {
load '../test_helper/common'
# your per-test setup
}
```
3. Write tests using bats-assert functions:
```bash
@test "example test" {
run my_command
assert_success
assert_output --partial "expected text"
}
```
## Available Assertions (bats-assert)
| Assertion | Description |
|-----------|-------------|
| `assert_success` | Command exited with status 0 |
| `assert_failure` | Command exited with non-zero status |
| `assert_output "text"` | Check exact output |
| `assert_output --partial "text"` | Check output contains text |
| `assert_output --regexp "pattern"` | Check output matches regex |
| `assert_line "text"` | Check specific line in output |
| `assert_line --index 0 "text"` | Check line at index |
| `refute_output "text"` | Assert output does NOT contain text |
| `assert_regex "$var" "pattern"` | Assert variable matches regex |
## Helper Functions (common.bash)
### SSH Execution
| Function | Description |
|----------|-------------|
| `remote_exec <cmd>` | Execute command on remote server |
| `remote_exec_safe <cmd>` | Execute, ignoring failures |
### Process Management
| Function | Description |
|----------|-------------|
| `start_api` | Start the API and wait for ready |
| `stop_api [--force]` | Stop the API |
| `cleanup` | Kill all API processes |
| `get_remote_pid` | Get PID from remote PID file |
| `pid_file_exists` | Check if PID file exists |
| `is_process_running <pid>` | Check if process is running |
### Assertions
| Function | Description |
|----------|-------------|
| `assert_single_api_instance` | Verify exactly one API running |
| `assert_no_api_processes` | Verify no API processes |
### Wait Helpers
| Function | Description |
|----------|-------------|
| `wait_for_start [timeout]` | Wait for API to start |
| `wait_for_stop [timeout]` | Wait for API to stop |
## Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `SERVER` | Yes | SSH server name or IP address |
| `DEFAULT_TIMEOUT` | No | Test timeout in seconds (default: 10) |
## Directory Structure
```
tests/bats/
├── test_helper/
│ ├── common.bash # Shared helper functions
│ ├── bats-support/ # Symlink to node_modules
│ └── bats-assert/ # Symlink to node_modules
├── integration/
│ └── singleton.bats # Singleton process tests
└── README.md # This file
```

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env bats
# example.bats - Example test file demonstrating bats-assert and bats-support
#
# Run with: pnpm test:bats
# Or directly: bats tests/bats/examples/example.bats
#
# This file demonstrates common BATS patterns and assertions.
# Delete or modify this file as needed.
# -----------------------------------------------------------------------------
# Setup
# -----------------------------------------------------------------------------
setup() {
# Load assertion libraries
load '../test_helper/bats-support/load'
load '../test_helper/bats-assert/load'
}
# -----------------------------------------------------------------------------
# Basic Assertions
# -----------------------------------------------------------------------------
@test "assert_success: command exits with status 0" {
run echo "hello"
assert_success
}
@test "assert_failure: command exits with non-zero status" {
run false
assert_failure
}
@test "assert_failure with specific exit code" {
run bash -c "exit 42"
assert_failure 42
}
# -----------------------------------------------------------------------------
# Output Assertions
# -----------------------------------------------------------------------------
@test "assert_output: exact match" {
run echo "hello world"
assert_output "hello world"
}
@test "assert_output --partial: contains substring" {
run echo "hello world"
assert_output --partial "world"
}
@test "assert_output --regexp: matches regex" {
run echo "file_2024_01_15.txt"
assert_output --regexp "file_[0-9]{4}_[0-9]{2}_[0-9]{2}\.txt"
}
@test "refute_output: output does NOT contain" {
run echo "success"
refute_output --partial "error"
}
# -----------------------------------------------------------------------------
# Line Assertions
# -----------------------------------------------------------------------------
@test "assert_line: output contains line" {
run bash -c "echo -e 'line1\nline2\nline3'"
assert_line "line2"
}
@test "assert_line --index: specific line number" {
run bash -c "echo -e 'first\nsecond\nthird'"
assert_line --index 0 "first"
assert_line --index 1 "second"
assert_line --index 2 "third"
}
@test "assert_line --partial: line contains substring" {
run bash -c "echo -e 'error: something failed\nwarning: check this'"
assert_line --partial "something failed"
}
@test "refute_line: output does NOT contain line" {
run bash -c "echo -e 'info: ok\ninfo: done'"
refute_line --partial "error"
}
# -----------------------------------------------------------------------------
# Variable Assertions
# -----------------------------------------------------------------------------
@test "assert: test expression" {
result="hello"
assert [ -n "$result" ]
assert [ "$result" = "hello" ]
}
@test "assert_equal: two values are equal" {
expected="42"
actual="42"
assert_equal "$expected" "$actual"
}
@test "assert_not_equal: two values differ" {
value1="foo"
value2="bar"
assert_not_equal "$value1" "$value2"
}
@test "assert_regex: variable matches pattern" {
version="v1.2.3"
assert_regex "$version" "^v[0-9]+\.[0-9]+\.[0-9]+$"
}
# -----------------------------------------------------------------------------
# Working with Commands
# -----------------------------------------------------------------------------
@test "capture stdout and stderr separately" {
run bash -c "echo 'stdout message'; echo 'stderr message' >&2"
# $output contains both stdout and stderr by default
assert_output --partial "stdout message"
assert_output --partial "stderr message"
}
@test "check command exists" {
run command -v bash
assert_success
}
@test "working with JSON output (using grep)" {
run echo '{"status": "ok", "count": 5}'
assert_output --partial '"status": "ok"'
}
# -----------------------------------------------------------------------------
# Skipping Tests
# -----------------------------------------------------------------------------
@test "skip: conditionally skip a test" {
if [[ -z "${RUN_SLOW_TESTS:-}" ]]; then
skip "RUN_SLOW_TESTS not set"
fi
# This would be a slow test...
run sleep 0.1
assert_success
}
@test "skip based on environment" {
[[ -n "${CI:-}" ]] || skip "Only runs in CI"
run echo "running in CI"
assert_success
}

View File

@@ -1,321 +0,0 @@
#!/usr/bin/env bats
# singleton_daemon.bats - Tests for unraid-api singleton daemon process management
#
# Usage: SERVER=<server_name> bats singleton.bats
# SERVER=<server_name> pnpm test:bats:integration
#
# These tests verify that the unraid-api is properly daemonized as a singleton process.
# See: https://bats-core.readthedocs.io/en/stable/writing-tests.html
# -----------------------------------------------------------------------------
# Test Setup
# -----------------------------------------------------------------------------
# setup_file runs once before all tests in this file
setup_file() {
load '../test_helper/unraid-api'
}
# Setup runs before each test - ensure clean state
setup() {
load '../test_helper/unraid-api'
cleanup
}
# Teardown runs after each test - clean up
teardown() {
cleanup
}
teardown_file() {
load '../test_helper/unraid-api'
start_api
}
# -----------------------------------------------------------------------------
# Start Command Tests
# -----------------------------------------------------------------------------
@test "start: creates a single process with PID file" {
# Start the API
run start_api
assert_success
# Verify PID file exists
run pid_file_exists
assert_success
# Verify PID is valid (non-empty and numeric)
pid=$(get_remote_pid)
assert [ -n "$pid" ]
assert_regex "$pid" '^[0-9]+$'
# Verify process is running
run is_process_running "$pid"
assert_success
# Verify exactly ONE nodemon AND ONE main.js
run assert_single_api_instance
assert_success
}
@test "start: second start does not create duplicate process" {
# Start the API first
run start_api
assert_success
# Get the initial PID
initial_pid=$(get_remote_pid)
assert [ -n "$initial_pid" ]
# Verify single instance initially
run assert_single_api_instance
assert_success
# Try to start again
run remote_exec "unraid-api start"
# Should succeed (either no-op or restart)
# Wait a moment for any process changes
sleep 2
# Verify still exactly one nodemon AND one main.js (singleton enforcement)
run assert_single_api_instance
assert_success
# Verify process is still running (either same or new PID after restart)
run pid_file_exists
assert_success
final_pid=$(get_remote_pid)
assert [ -n "$final_pid" ]
run is_process_running "$final_pid"
assert_success
}
@test "start: cleans up stale PID file" {
# Create a stale PID file with a non-existent PID
run remote_exec "mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'"
assert_success
# Start should clean up and proceed
run start_api
assert_success
# Verify new valid PID (not the stale one)
pid=$(get_remote_pid)
assert [ -n "$pid" ]
assert [ "$pid" != "99999" ]
# Verify process is actually running
run is_process_running "$pid"
assert_success
}
@test "start: cleans up orphaned nodemon process" {
# Start API normally
run start_api
assert_success
# Remove PID file but leave process running (simulate orphan)
run remote_exec "rm -f '${REMOTE_PID_PATH}'"
assert_success
# Verify orphaned process still running
count=$(count_nodemon_processes)
assert [ "$count" -eq 1 ]
# Start should detect orphan and clean it up
run start_api
assert_success
# Should still have exactly one process
count=$(count_nodemon_processes)
assert [ "$count" -eq 1 ]
# PID file should exist again
run pid_file_exists
assert_success
}
# -----------------------------------------------------------------------------
# Status Command Tests
# -----------------------------------------------------------------------------
@test "status: reports running when API is active" {
# Start the API
run start_api
assert_success
# Check status - should contain "running"
output=$(get_status)
assert_regex "$output" "running"
}
@test "status: reports not running when API is stopped" {
# Ensure API is stopped (cleanup already called in setup)
# Check status - should indicate not running
output=$(get_status)
assert_regex "$output" "not running"
}
# -----------------------------------------------------------------------------
# Stop Command Tests
# -----------------------------------------------------------------------------
@test "stop: cleanly terminates all processes" {
# Start the API first
run start_api
assert_success
# Verify it's running
pid=$(get_remote_pid)
assert [ -n "$pid" ]
# Verify single instance before stop
run assert_single_api_instance
assert_success
# Stop the API
run stop_api
assert_success
# Verify PID file is removed
run pid_file_exists
assert_failure
# Verify NO nodemon AND NO main.js processes remain
run assert_no_api_processes
assert_success
}
@test "stop --force: terminates all processes immediately" {
# Start the API
run start_api
assert_success
# Get the PID
pid=$(get_remote_pid)
assert [ -n "$pid" ]
# Verify single instance before stop
run assert_single_api_instance
assert_success
# Force stop
run stop_api --force
assert_success
# Verify PID file is removed
run pid_file_exists
assert_failure
# Verify NO processes remain (nodemon AND main.js)
run assert_no_api_processes
assert_success
}
# -----------------------------------------------------------------------------
# Restart Command Tests
# -----------------------------------------------------------------------------
@test "restart: creates new process when already running" {
# Start the API
run start_api
assert_success
# Get the initial PID
initial_pid=$(get_remote_pid)
assert [ -n "$initial_pid" ]
# Verify single instance initially
run assert_single_api_instance
assert_success
# Restart the API
run remote_exec "unraid-api restart"
assert_success
# Wait for restart to complete
sleep 3
run wait_for_start 10
assert_success
# Get new PID
new_pid=$(get_remote_pid)
assert [ -n "$new_pid" ]
# PIDs should be different (process was actually restarted)
assert [ "$initial_pid" != "$new_pid" ]
# Verify exactly one nodemon AND one main.js after restart
run assert_single_api_instance
assert_success
}
@test "restart: works when API is not running" {
# Ensure API is stopped (cleanup already called in setup)
# Restart should start the API
run remote_exec "unraid-api restart"
assert_success
# Wait for start
run wait_for_start 10
assert_success
# Verify process is running
pid=$(get_remote_pid)
assert [ -n "$pid" ]
run is_process_running "$pid"
assert_success
}
# -----------------------------------------------------------------------------
# Edge Case Tests
# -----------------------------------------------------------------------------
@test "concurrent starts: result in single process" {
# Launch multiple starts concurrently
run remote_exec "unraid-api start & unraid-api start & wait"
# Wait for things to settle
sleep 3
# Must have exactly one nodemon AND one main.js, not just "one nodemon"
run assert_single_api_instance
assert_success
# PID file should exist
run pid_file_exists
assert_success
}
@test "recovery: API recovers after process is killed externally" {
# Start the API
run start_api
assert_success
pid=$(get_remote_pid)
assert [ -n "$pid" ]
# Kill the process directly (simulate crash)
run remote_exec "kill -9 '$pid'"
# Wait for process to die
sleep 1
# Start should recover
run start_api
assert_success
# Verify new process running
new_pid=$(get_remote_pid)
assert [ -n "$new_pid" ]
run is_process_running "$new_pid"
assert_success
}

View File

@@ -1,16 +0,0 @@
{
"name": "@unraid/bats-tests",
"version": "0.0.0",
"private": true,
"description": "BATS integration tests for unraid-api",
"scripts": {
"postinstall": "./setup.sh",
"test": "bats --recursive .",
"test:integration": "bats --recursive integration/"
},
"devDependencies": {
"bats": "^1.13.0",
"bats-assert": "^2.2.4",
"bats-support": "^0.3.0"
}
}

View File

@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# setup.sh - Setup symlinks for BATS libraries from node_modules
#
# This script is called by postinstall to link bats-support and bats-assert
# from node_modules into test_helper/ where BATS can load them.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
NODE_MODULES="$SCRIPT_DIR/node_modules"
# Create test_helper directory if needed
mkdir -p "$SCRIPT_DIR/test_helper"
# Create symlinks (use -f to overwrite existing)
if [[ -d "$NODE_MODULES/bats-support" ]]; then
ln -sfn "$NODE_MODULES/bats-support" "$SCRIPT_DIR/test_helper/bats-support"
fi
if [[ -d "$NODE_MODULES/bats-assert" ]]; then
ln -sfn "$NODE_MODULES/bats-assert" "$SCRIPT_DIR/test_helper/bats-assert"
fi
echo "BATS libraries linked successfully"

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
# common.bash - Base helper that loads BATS assertion libraries
#
# Usage in tests:
# load '../test_helper/common'
load 'bats-support/load'
load 'bats-assert/load'

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# ssh.bash - SSH execution helpers for remote server testing
#
# Requires: SERVER environment variable
#
# Usage in tests:
# load '../test_helper/ssh'
load 'common'
: "${SERVER:?SERVER environment variable must be set}"
# Execute a command on the remote server
remote_exec() {
ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@"
}
# Execute a command on the remote server, ignoring failures (for cleanup)
remote_exec_safe() {
ssh -o ConnectTimeout=10 -o BatchMode=yes -o StrictHostKeyChecking=accept-new "root@${SERVER}" "$@" 2>/dev/null || true
}

View File

@@ -1,229 +0,0 @@
#!/usr/bin/env bash
# unraid-api.bash - Helpers for testing unraid-api daemon process management
#
# Requires: SERVER environment variable
#
# Usage in tests:
# load '../test_helper/unraid-api'
load 'ssh'
# Remote paths
export REMOTE_PID_PATH="/var/run/unraid-api/nodemon.pid"
# Timeouts (seconds)
export DEFAULT_TIMEOUT=10
# -----------------------------------------------------------------------------
# Process Query Helpers
# -----------------------------------------------------------------------------
# Get the PID from the remote PID file, returns empty if not found
get_remote_pid() {
remote_exec "cat '${REMOTE_PID_PATH}' 2>/dev/null || true" | tr -d '[:space:]'
}
# Check if the PID file exists on the remote server (returns 0 if exists, 1 if not)
pid_file_exists() {
remote_exec "test -f '${REMOTE_PID_PATH}'" 2>/dev/null
}
# Check if a process is running on the remote server
is_process_running() {
local pid="$1"
[[ -n "$pid" ]] && remote_exec "kill -0 '${pid}' 2>/dev/null"
}
# Count nodemon processes matching our config on remote server
count_nodemon_processes() {
local result
result=$(remote_exec "ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l" 2>/dev/null || echo "0")
echo "${result}" | tr -d '[:space:]'
}
# Count main.js worker processes (children of nodemon)
count_main_processes() {
local result
result=$(remote_exec "ps -eo args 2>/dev/null | grep -E 'node.*dist/main\.js' | grep -v grep | wc -l" 2>/dev/null || echo "0")
echo "${result}" | tr -d '[:space:]'
}
# Count all unraid-api related processes (nodemon + main.js)
count_unraid_api_processes() {
local nodemon_count main_count
nodemon_count=$(count_nodemon_processes)
main_count=$(count_main_processes)
echo $((nodemon_count + main_count))
}
# -----------------------------------------------------------------------------
# Process Assertions
# -----------------------------------------------------------------------------
# Assert exactly one nodemon and one main.js process
assert_single_api_instance() {
local nodemon_count main_count
nodemon_count=$(count_nodemon_processes)
main_count=$(count_main_processes)
if [[ "$nodemon_count" -ne 1 ]]; then
echo "Expected 1 nodemon process, found $nodemon_count" >&2
remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2
return 1
fi
if [[ "$main_count" -ne 1 ]]; then
echo "Expected 1 main.js process, found $main_count" >&2
remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2
return 1
fi
return 0
}
# Assert no API processes running
assert_no_api_processes() {
local nodemon_count main_count
nodemon_count=$(count_nodemon_processes)
main_count=$(count_main_processes)
if [[ "$nodemon_count" -ne 0 ]] || [[ "$main_count" -ne 0 ]]; then
echo "Expected 0 processes, found nodemon=$nodemon_count main.js=$main_count" >&2
remote_exec "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2
return 1
fi
return 0
}
# -----------------------------------------------------------------------------
# Wait Helpers
# -----------------------------------------------------------------------------
# Wait for a process to start (PID file to exist and process running)
wait_for_start() {
local timeout="${1:-$DEFAULT_TIMEOUT}"
local deadline=$((SECONDS + timeout))
while [[ $SECONDS -lt $deadline ]]; do
local pid
pid=$(get_remote_pid)
if [[ -n "$pid" ]] && is_process_running "$pid"; then
return 0
fi
sleep 1
done
return 1
}
# Wait for a process to stop (PID file removed or process not running)
wait_for_stop() {
local timeout="${1:-$DEFAULT_TIMEOUT}"
local deadline=$((SECONDS + timeout))
while [[ $SECONDS -lt $deadline ]]; do
local pid
pid=$(get_remote_pid)
if [[ -z "$pid" ]]; then
return 0
fi
if ! is_process_running "$pid"; then
return 0
fi
sleep 1
done
return 1
}
# Wait for all unraid-api processes to stop
wait_for_all_processes_stop() {
local timeout="${1:-$DEFAULT_TIMEOUT}"
local deadline=$((SECONDS + timeout))
while [[ $SECONDS -lt $deadline ]]; do
local count
count=$(count_unraid_api_processes)
if [[ "$count" -eq 0 ]]; then
return 0
fi
sleep 1
done
return 1
}
# -----------------------------------------------------------------------------
# API Lifecycle Helpers
# -----------------------------------------------------------------------------
# Clean up: stop any running unraid-api processes
cleanup() {
# Step 1: Try graceful stop via unraid-api
remote_exec_safe "unraid-api stop 2>/dev/null; true"
sleep 1
# Step 2: Check if processes remain
local count
count=$(count_unraid_api_processes)
if [[ "$count" -eq 0 ]]; then
remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true"
return 0
fi
# Step 3: Force kill - nodemon FIRST (prevents restart of child)
remote_exec_safe "pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true"
sleep 0.5
# Step 4: Force kill - then main.js children
remote_exec_safe "pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true"
sleep 1
# Step 5: Clean up PID file
remote_exec_safe "rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true"
# Step 6: Verify - if still running, try harder with explicit PIDs
count=$(count_unraid_api_processes)
if [[ "$count" -ne 0 ]]; then
local pids
pids=$(remote_exec_safe "ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print \$1}'" 2>/dev/null || true)
for pid in $pids; do
remote_exec_safe "kill -9 $pid 2>/dev/null; true"
done
sleep 1
fi
# Final check
count=$(count_unraid_api_processes)
if [[ "$count" -ne 0 ]]; then
echo "WARNING: Cleanup incomplete, remaining processes:" >&2
remote_exec_safe "ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep" >&2
fi
return 0
}
# Start the API and wait for it to be ready
start_api() {
remote_exec "unraid-api start"
wait_for_start
}
# Stop the API using unraid-api stop command
stop_api() {
local force="${1:-}"
if [[ "$force" == "--force" ]]; then
remote_exec "unraid-api stop --force"
else
remote_exec "unraid-api stop"
fi
wait_for_stop
wait_for_all_processes_stop 10
}
# Get status output from remote
get_status() {
remote_exec "unraid-api status 2>&1" || true
}

View File

@@ -0,0 +1,11 @@
/**
* @see https://prettier.io/docs/en/configuration.html
* @type {import("prettier").Config}
*/
module.exports = {
trailingComma: 'es5',
tabWidth: 4,
semi: true,
singleQuote: true,
printWidth: 105,
};

View File

@@ -1,186 +1,22 @@
# System Integration Tests
TypeScript + Vitest integration tests for the unraid-api daemon process management. These tests validate singleton daemon behavior by executing commands on a remote Unraid server via SSH.
Integration tests that run against a live Unraid server via SSH.
## Prerequisites
- Node.js 22+
- pnpm
- SSH key-based authentication to the target Unraid server
- The target server must have `unraid-api` installed and accessible
## Installation
```bash
cd tests/system-integration
pnpm install
```
Or from the monorepo root:
```bash
pnpm install
```
- `unraid-api` installed on the target server
## Usage
### Running Tests
Tests require the `SERVER` environment variable to specify the target Unraid server:
```bash
# Run all tests
# Run tests
SERVER=tower pnpm test
# Run tests in watch mode
SERVER=tower pnpm test:watch
# From monorepo root
SERVER=tower pnpm test:system
```
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `SERVER` | Yes | Hostname or IP address of the target Unraid server |
## Test Coverage
The test suite validates daemon singleton process management:
### Start Command Tests
- Creates a single process with PID file
- Second start does not create duplicate process
- Cleans up stale PID file
- Cleans up orphaned nodemon process
### Status Command Tests
- Reports running when API is active
- Reports not running when API is stopped
### Stop Command Tests
- Cleanly terminates all processes
- Force stop terminates all processes immediately
### Restart Command Tests
- Creates new process when already running
- Works when API is not running
### Edge Case Tests
- Concurrent starts result in single process
- API recovers after process is killed externally
## Architecture
### Process Model
The unraid-api runs as a singleton daemon with two processes:
```
nodemon (supervisor)
└── node dist/main.js (worker)
```
- **nodemon**: Process supervisor that monitors and restarts the main process
- **main.js**: The actual API server
### PID File
The daemon tracks its process via a PID file:
```
/var/run/unraid-api/nodemon.pid
```
## Project Structure
```
tests/system-integration/
├── package.json
├── tsconfig.json
├── vitest.config.ts
├── README.md
└── src/
├── helpers/
│ ├── ssh.ts # SSH execution via execa
│ ├── process.ts # Process query/assertion helpers
│ └── api-lifecycle.ts # Start/stop/cleanup helpers
└── tests/
└── singleton-daemon.test.ts
```
### Helper Modules
#### `ssh.ts`
Remote command execution via SSH:
- `remoteExec(cmd)` - Execute command, return result
- `remoteExecSafe(cmd)` - Execute command, ignore failures (for cleanup)
#### `process.ts`
Process inspection and assertions:
- `getRemotePid()` - Read PID from file
- `pidFileExists()` - Check PID file existence
- `isProcessRunning(pid)` - Verify process is alive
- `countNodemonProcesses()` - Count nodemon instances
- `countMainProcesses()` - Count main.js workers
- `assertSingleApiInstance()` - Assert exactly 1 nodemon + 1 main.js
- `assertNoApiProcesses()` - Assert all processes stopped
#### `api-lifecycle.ts`
High-level daemon management:
- `startApi()` - Start and wait for ready
- `stopApi(force?)` - Stop with optional force flag
- `cleanup()` - Multi-step process cleanup
- `waitForStart(timeout)` - Poll until started
- `waitForStop(timeout)` - Poll until stopped
- `getStatus()` - Get status output
## Configuration
### Vitest Configuration
The tests run sequentially (not in parallel) since they interact with shared server state:
```typescript
// vitest.config.ts
export default defineConfig({
test: {
globals: true,
testTimeout: 60000, // SSH operations can be slow
hookTimeout: 60000,
sequence: {
concurrent: false, // Run tests sequentially
},
pool: 'forks',
poolOptions: {
forks: {
singleFork: true, // Single process for all tests
},
},
},
});
```
### SSH Configuration
SSH connections use these options:
- `ConnectTimeout=10` - 10 second connection timeout
- `BatchMode=yes` - Disable password prompts (requires key auth)
- `StrictHostKeyChecking=accept-new` - Auto-accept new host keys
## Comparison with BATS Tests
This package is a TypeScript port of the BATS test suite in `tests/bats/`. Key differences:
| Feature | BATS | TypeScript/Vitest |
|---------|------|-------------------|
| Language | Bash | TypeScript |
| Test Runner | bats-core | Vitest |
| Assertions | bats-assert | Vitest expect() |
| SSH Execution | Raw ssh command | execa |
| Async Model | Synchronous shell | Async/await |
| Type Safety | None | Full TypeScript types |
## Troubleshooting
### SSH Connection Fails
@@ -188,26 +24,15 @@ This package is a TypeScript port of the BATS test suite in `tests/bats/`. Key d
Ensure SSH key authentication is configured:
```bash
# Test SSH connection
ssh root@tower echo "Connected"
# If prompted for password, set up key auth:
# If prompted for password:
ssh-copy-id root@tower
```
### Tests Time Out
Increase timeouts in `vitest.config.ts` or individual tests:
```typescript
it('slow test', async () => {
// ...
}, 120000); // 2 minute timeout
```
### Processes Not Cleaned Up
If tests fail and leave processes running, manually clean up:
If tests fail and leave processes running:
```bash
ssh root@tower 'unraid-api stop --force; pkill -f nodemon; pkill -f main.js'

View File

@@ -0,0 +1,19 @@
import eslint from '@eslint/js';
import tseslint from 'typescript-eslint';
export default tseslint.config(
eslint.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.ts'],
rules: {
'@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'no-multiple-empty-lines': ['error', { max: 1, maxBOF: 0, maxEOF: 1 }],
'eol-last': ['error', 'always'],
},
},
{
ignores: ['node_modules/**/*', 'dist/**/*'],
}
);

View File

@@ -6,11 +6,20 @@
"description": "System integration tests for unraid-api",
"scripts": {
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"lint": "pnpm lint:eslint && pnpm lint:prettier",
"lint:eslint": "eslint --cache --config eslint.config.ts src/",
"lint:prettier": "prettier --check \"src/**/*.ts\"",
"lint:fix": "eslint --cache --fix --config eslint.config.ts src/ && prettier --write \"src/**/*.ts\""
},
"devDependencies": {
"@eslint/js": "^9.34.0",
"eslint": "^9.34.0",
"execa": "^9.6.0",
"jiti": "^2.5.1",
"prettier": "^3.6.2",
"typescript": "^5.9.2",
"typescript-eslint": "^8.41.0",
"vitest": "^3.2.4"
}
}

View File

@@ -23,12 +23,7 @@
*/
import { remoteExec, remoteExecSafe } from './ssh.js';
import {
getRemotePid,
isProcessRunning,
countUnraidApiProcesses,
REMOTE_PID_PATH,
} from './process.js';
import { getRemotePid, isProcessRunning, countUnraidApiProcesses, REMOTE_PID_PATH } from './process.js';
/**
* Default timeout for wait operations in milliseconds.
@@ -40,7 +35,7 @@ const DEFAULT_TIMEOUT = 10000;
* @param ms - Duration to sleep in milliseconds
*/
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
@@ -59,17 +54,17 @@ function sleep(ms: number): Promise<void> {
* ```
*/
export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise<boolean> {
const deadline = Date.now() + timeout;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const pid = await getRemotePid();
if (pid && (await isProcessRunning(pid))) {
return true;
while (Date.now() < deadline) {
const pid = await getRemotePid();
if (pid && (await isProcessRunning(pid))) {
return true;
}
await sleep(1000);
}
await sleep(1000);
}
return false;
return false;
}
/**
@@ -86,20 +81,20 @@ export async function waitForStart(timeout = DEFAULT_TIMEOUT): Promise<boolean>
* ```
*/
export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise<boolean> {
const deadline = Date.now() + timeout;
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const pid = await getRemotePid();
if (!pid) {
return true;
while (Date.now() < deadline) {
const pid = await getRemotePid();
if (!pid) {
return true;
}
if (!(await isProcessRunning(pid))) {
return true;
}
await sleep(1000);
}
if (!(await isProcessRunning(pid))) {
return true;
}
await sleep(1000);
}
return false;
return false;
}
/**
@@ -115,20 +110,18 @@ export async function waitForStop(timeout = DEFAULT_TIMEOUT): Promise<boolean> {
* expect(allStopped).toBe(true);
* ```
*/
export async function waitForAllProcessesStop(
timeout = DEFAULT_TIMEOUT
): Promise<boolean> {
const deadline = Date.now() + timeout;
export async function waitForAllProcessesStop(timeout = DEFAULT_TIMEOUT): Promise<boolean> {
const deadline = Date.now() + timeout;
while (Date.now() < deadline) {
const count = await countUnraidApiProcesses();
if (count === 0) {
return true;
while (Date.now() < deadline) {
const count = await countUnraidApiProcesses();
if (count === 0) {
return true;
}
await sleep(1000);
}
await sleep(1000);
}
return false;
return false;
}
/**
@@ -156,49 +149,49 @@ export async function waitForAllProcessesStop(
* ```
*/
export async function cleanup(): Promise<void> {
// Step 1: Try graceful stop via unraid-api
await remoteExecSafe('unraid-api stop 2>/dev/null; true');
await sleep(1000);
// Step 2: Check if processes remain
let count = await countUnraidApiProcesses();
if (count === 0) {
await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`);
return;
}
// Step 3: Force kill - nodemon FIRST (prevents restart of child)
await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true");
await sleep(500);
// Step 4: Force kill - then main.js children
await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true");
await sleep(1000);
// Step 5: Clean up PID file
await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`);
// Step 6: Verify - if still running, try harder with explicit PIDs
count = await countUnraidApiProcesses();
if (count !== 0) {
const pidsResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'"
);
const pids = pidsResult.stdout.trim().split('\n').filter(Boolean);
for (const pid of pids) {
await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`);
}
// Step 1: Try graceful stop via unraid-api
await remoteExecSafe('unraid-api stop 2>/dev/null; true');
await sleep(1000);
}
// Final check
count = await countUnraidApiProcesses();
if (count !== 0) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`);
}
// Step 2: Check if processes remain
let count = await countUnraidApiProcesses();
if (count === 0) {
await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`);
return;
}
// Step 3: Force kill - nodemon FIRST (prevents restart of child)
await remoteExecSafe("pkill -KILL -f 'nodemon.*nodemon.json' 2>/dev/null; true");
await sleep(500);
// Step 4: Force kill - then main.js children
await remoteExecSafe("pkill -KILL -f 'node.*dist/main.js' 2>/dev/null; true");
await sleep(1000);
// Step 5: Clean up PID file
await remoteExecSafe(`rm -f '${REMOTE_PID_PATH}' 2>/dev/null; true`);
// Step 6: Verify - if still running, try harder with explicit PIDs
count = await countUnraidApiProcesses();
if (count !== 0) {
const pidsResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon.*nodemon.json|node.*dist/main.js' | grep -v grep | awk '{print $1}'"
);
const pids = pidsResult.stdout.trim().split('\n').filter(Boolean);
for (const pid of pids) {
await remoteExecSafe(`kill -9 ${pid} 2>/dev/null; true`);
}
await sleep(1000);
}
// Final check
count = await countUnraidApiProcesses();
if (count !== 0) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
console.warn(`WARNING: Cleanup incomplete, remaining processes:\n${psResult.stdout}`);
}
}
/**
@@ -214,14 +207,14 @@ export async function cleanup(): Promise<void> {
* ```
*/
export async function startApi(): Promise<void> {
const result = await remoteExec('unraid-api start');
if (result.exitCode !== 0) {
throw new Error(`Failed to start API: ${result.stderr}`);
}
const started = await waitForStart();
if (!started) {
throw new Error('API did not start within timeout');
}
const result = await remoteExec('unraid-api start');
if (result.exitCode !== 0) {
throw new Error(`Failed to start API: ${result.stderr}`);
}
const started = await waitForStart();
if (!started) {
throw new Error('API did not start within timeout');
}
}
/**
@@ -241,13 +234,13 @@ export async function startApi(): Promise<void> {
* ```
*/
export async function stopApi(force = false): Promise<void> {
const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop';
const result = await remoteExec(cmd);
if (result.exitCode !== 0) {
throw new Error(`Failed to stop API: ${result.stderr}`);
}
await waitForStop();
await waitForAllProcessesStop(10000);
const cmd = force ? 'unraid-api stop --force' : 'unraid-api stop';
const result = await remoteExec(cmd);
if (result.exitCode !== 0) {
throw new Error(`Failed to stop API: ${result.stderr}`);
}
await waitForStop();
await waitForAllProcessesStop(10000);
}
/**
@@ -266,6 +259,6 @@ export async function stopApi(force = false): Promise<void> {
* ```
*/
export async function getStatus(): Promise<string> {
const result = await remoteExec('unraid-api status 2>&1');
return result.stdout;
const result = await remoteExec('unraid-api status 2>&1');
return result.stdout;
}

View File

@@ -41,8 +41,8 @@ export const REMOTE_PID_PATH = '/var/run/unraid-api/nodemon.pid';
* ```
*/
export async function getRemotePid(): Promise<string> {
const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`);
return result.stdout.trim();
const result = await remoteExec(`cat '${REMOTE_PID_PATH}' 2>/dev/null || true`);
return result.stdout.trim();
}
/**
@@ -58,8 +58,8 @@ export async function getRemotePid(): Promise<string> {
* ```
*/
export async function pidFileExists(): Promise<boolean> {
const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`);
return result.exitCode === 0;
const result = await remoteExec(`test -f '${REMOTE_PID_PATH}'`);
return result.exitCode === 0;
}
/**
@@ -78,9 +78,9 @@ export async function pidFileExists(): Promise<boolean> {
* ```
*/
export async function isProcessRunning(pid: string): Promise<boolean> {
if (!pid) return false;
const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`);
return result.exitCode === 0;
if (!pid) return false;
const result = await remoteExec(`kill -0 '${pid}' 2>/dev/null`);
return result.exitCode === 0;
}
/**
@@ -96,11 +96,11 @@ export async function isProcessRunning(pid: string): Promise<boolean> {
* ```
*/
export async function countNodemonProcesses(): Promise<number> {
const result = await remoteExecSafe(
"ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l"
);
const count = parseInt(result.stdout.trim(), 10);
return isNaN(count) ? 0 : count;
const result = await remoteExecSafe(
"ps -eo pid,args 2>/dev/null | grep -E 'nodemon.*nodemon.json' | grep -v grep | wc -l"
);
const count = parseInt(result.stdout.trim(), 10);
return isNaN(count) ? 0 : count;
}
/**
@@ -116,11 +116,11 @@ export async function countNodemonProcesses(): Promise<number> {
* ```
*/
export async function countMainProcesses(): Promise<number> {
const result = await remoteExecSafe(
"ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l"
);
const count = parseInt(result.stdout.trim(), 10);
return isNaN(count) ? 0 : count;
const result = await remoteExecSafe(
"ps -eo args 2>/dev/null | grep -E 'node.*dist/main\\.js' | grep -v grep | wc -l"
);
const count = parseInt(result.stdout.trim(), 10);
return isNaN(count) ? 0 : count;
}
/**
@@ -136,9 +136,9 @@ export async function countMainProcesses(): Promise<number> {
* ```
*/
export async function countUnraidApiProcesses(): Promise<number> {
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
return nodemonCount + mainCount;
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
return nodemonCount + mainCount;
}
/**
@@ -154,26 +154,22 @@ export async function countUnraidApiProcesses(): Promise<number> {
* ```
*/
export async function assertSingleApiInstance(): Promise<void> {
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
if (nodemonCount !== 1) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(
`Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}`
);
}
if (nodemonCount !== 1) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(`Expected 1 nodemon process, found ${nodemonCount}\n${psResult.stdout}`);
}
if (mainCount !== 1) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(
`Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}`
);
}
if (mainCount !== 1) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(`Expected 1 main.js process, found ${mainCount}\n${psResult.stdout}`);
}
}
/**
@@ -189,15 +185,15 @@ export async function assertSingleApiInstance(): Promise<void> {
* ```
*/
export async function assertNoApiProcesses(): Promise<void> {
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
const nodemonCount = await countNodemonProcesses();
const mainCount = await countMainProcesses();
if (nodemonCount !== 0 || mainCount !== 0) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(
`Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}`
);
}
if (nodemonCount !== 0 || mainCount !== 0) {
const psResult = await remoteExecSafe(
"ps -eo pid,args | grep -E 'nodemon|main.js' | grep -v grep"
);
throw new Error(
`Expected 0 processes, found nodemon=${nodemonCount} main.js=${mainCount}\n${psResult.stdout}`
);
}
}

View File

@@ -23,12 +23,12 @@ import { execa } from 'execa';
* Result of a remote command execution.
*/
export interface ExecResult {
/** Standard output from the command */
stdout: string;
/** Standard error from the command */
stderr: string;
/** Exit code of the command (0 indicates success) */
exitCode: number;
/** Standard output from the command */
stdout: string;
/** Standard error from the command */
stderr: string;
/** Exit code of the command (0 indicates success) */
exitCode: number;
}
/**
@@ -37,11 +37,11 @@ export interface ExecResult {
* @returns The server hostname or IP address
*/
function getServer(): string {
const server = process.env.SERVER;
if (!server) {
throw new Error('SERVER environment variable must be set');
}
return server;
const server = process.env.SERVER;
if (!server) {
throw new Error('SERVER environment variable must be set');
}
return server;
}
/**
@@ -51,9 +51,12 @@ function getServer(): string {
* - StrictHostKeyChecking: Automatically accepts new host keys
*/
const SSH_OPTIONS = [
'-o', 'ConnectTimeout=10',
'-o', 'BatchMode=yes',
'-o', 'StrictHostKeyChecking=accept-new',
'-o',
'ConnectTimeout=10',
'-o',
'BatchMode=yes',
'-o',
'StrictHostKeyChecking=accept-new',
];
/**
@@ -71,16 +74,16 @@ const SSH_OPTIONS = [
* ```
*/
export async function remoteExec(cmd: string): Promise<ExecResult> {
const server = getServer();
const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], {
reject: false,
});
const server = getServer();
const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], {
reject: false,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode ?? 0,
};
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode ?? 0,
};
}
/**
@@ -97,23 +100,23 @@ export async function remoteExec(cmd: string): Promise<ExecResult> {
* ```
*/
export async function remoteExecSafe(cmd: string): Promise<ExecResult> {
const server = getServer();
try {
const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], {
reject: false,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode ?? 0,
};
} catch {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
const server = getServer();
try {
const result = await execa('ssh', [...SSH_OPTIONS, `root@${server}`, cmd], {
reject: false,
});
return {
stdout: result.stdout,
stderr: result.stderr,
exitCode: result.exitCode ?? 0,
};
} catch {
return {
stdout: '',
stderr: '',
exitCode: 0,
};
}
}
/**
@@ -123,5 +126,5 @@ export async function remoteExecSafe(cmd: string): Promise<ExecResult> {
* @returns The server hostname or IP address
*/
export function getServerName(): string {
return getServer();
return getServer();
}

View File

@@ -1,211 +1,205 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import { remoteExec } from '../helpers/ssh.js';
import {
getRemotePid,
pidFileExists,
isProcessRunning,
countNodemonProcesses,
assertSingleApiInstance,
assertNoApiProcesses,
REMOTE_PID_PATH,
getRemotePid,
pidFileExists,
isProcessRunning,
countNodemonProcesses,
assertSingleApiInstance,
assertNoApiProcesses,
REMOTE_PID_PATH,
} from '../helpers/process.js';
import {
cleanup,
startApi,
stopApi,
getStatus,
waitForStart,
} from '../helpers/api-lifecycle.js';
import { cleanup, startApi, stopApi, getStatus, waitForStart } from '../helpers/api-lifecycle.js';
describe('singleton daemon', () => {
beforeAll(async () => {
if (!process.env.SERVER) {
throw new Error('SERVER environment variable must be set');
}
});
afterAll(async () => {
await cleanup();
await startApi();
});
beforeEach(async () => {
await cleanup();
});
describe('start command', () => {
it('creates a single process with PID file', async () => {
await startApi();
expect(await pidFileExists()).toBe(true);
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(pid).toMatch(/^\d+$/);
expect(await isProcessRunning(pid)).toBe(true);
await assertSingleApiInstance();
beforeAll(async () => {
if (!process.env.SERVER) {
throw new Error('SERVER environment variable must be set');
}
});
it('second start does not create duplicate process', async () => {
await startApi();
const initialPid = await getRemotePid();
expect(initialPid).toBeTruthy();
await assertSingleApiInstance();
await remoteExec('unraid-api start');
await new Promise((resolve) => setTimeout(resolve, 2000));
await assertSingleApiInstance();
expect(await pidFileExists()).toBe(true);
const finalPid = await getRemotePid();
expect(finalPid).toBeTruthy();
expect(await isProcessRunning(finalPid)).toBe(true);
afterAll(async () => {
await cleanup();
await startApi();
});
it('cleans up stale PID file', async () => {
await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`);
await startApi();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(pid).not.toBe('99999');
expect(await isProcessRunning(pid)).toBe(true);
beforeEach(async () => {
await cleanup();
});
it('cleans up orphaned nodemon process', async () => {
await startApi();
describe('start command', () => {
it('creates a single process with PID file', async () => {
await startApi();
await remoteExec(`rm -f '${REMOTE_PID_PATH}'`);
expect(await pidFileExists()).toBe(true);
const count = await countNodemonProcesses();
expect(count).toBe(1);
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(pid).toMatch(/^\d+$/);
await startApi();
expect(await isProcessRunning(pid)).toBe(true);
const newCount = await countNodemonProcesses();
expect(newCount).toBe(1);
await assertSingleApiInstance();
});
expect(await pidFileExists()).toBe(true);
});
});
it('second start does not create duplicate process', async () => {
await startApi();
describe('status command', () => {
it('reports running when API is active', async () => {
await startApi();
const initialPid = await getRemotePid();
expect(initialPid).toBeTruthy();
const output = await getStatus();
expect(output).toMatch(/running/i);
await assertSingleApiInstance();
await remoteExec('unraid-api start');
await new Promise((resolve) => setTimeout(resolve, 2000));
await assertSingleApiInstance();
expect(await pidFileExists()).toBe(true);
const finalPid = await getRemotePid();
expect(finalPid).toBeTruthy();
expect(await isProcessRunning(finalPid)).toBe(true);
});
it('cleans up stale PID file', async () => {
await remoteExec(`mkdir -p /var/run/unraid-api && echo '99999' > '${REMOTE_PID_PATH}'`);
await startApi();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(pid).not.toBe('99999');
expect(await isProcessRunning(pid)).toBe(true);
});
it('cleans up orphaned nodemon process', async () => {
await startApi();
await remoteExec(`rm -f '${REMOTE_PID_PATH}'`);
const count = await countNodemonProcesses();
expect(count).toBe(1);
await startApi();
const newCount = await countNodemonProcesses();
expect(newCount).toBe(1);
expect(await pidFileExists()).toBe(true);
});
});
it('reports not running when API is stopped', async () => {
const output = await getStatus();
expect(output).toMatch(/not running/i);
});
});
describe('status command', () => {
it('reports running when API is active', async () => {
await startApi();
describe('stop command', () => {
it('cleanly terminates all processes', async () => {
await startApi();
const output = await getStatus();
expect(output).toMatch(/running/i);
});
const pid = await getRemotePid();
expect(pid).toBeTruthy();
await assertSingleApiInstance();
await stopApi();
expect(await pidFileExists()).toBe(false);
await assertNoApiProcesses();
it('reports not running when API is stopped', async () => {
const output = await getStatus();
expect(output).toMatch(/not running/i);
});
});
it('stop --force terminates all processes immediately', async () => {
await startApi();
describe('stop command', () => {
it('cleanly terminates all processes', async () => {
await startApi();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
await assertSingleApiInstance();
await assertSingleApiInstance();
await stopApi(true);
await stopApi();
expect(await pidFileExists()).toBe(false);
expect(await pidFileExists()).toBe(false);
await assertNoApiProcesses();
});
});
await assertNoApiProcesses();
});
describe('restart command', () => {
it('creates new process when already running', async () => {
await startApi();
it('stop --force terminates all processes immediately', async () => {
await startApi();
const initialPid = await getRemotePid();
expect(initialPid).toBeTruthy();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
await assertSingleApiInstance();
await assertSingleApiInstance();
await remoteExec('unraid-api restart');
await stopApi(true);
await new Promise((resolve) => setTimeout(resolve, 3000));
await waitForStart(10000);
expect(await pidFileExists()).toBe(false);
const newPid = await getRemotePid();
expect(newPid).toBeTruthy();
expect(initialPid).not.toBe(newPid);
await assertSingleApiInstance();
await assertNoApiProcesses();
});
});
it('works when API is not running', async () => {
await remoteExec('unraid-api restart');
describe('restart command', () => {
it('creates new process when already running', async () => {
await startApi();
await waitForStart(10000);
const initialPid = await getRemotePid();
expect(initialPid).toBeTruthy();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
await assertSingleApiInstance();
expect(await isProcessRunning(pid)).toBe(true);
});
});
await remoteExec('unraid-api restart');
describe('edge cases', () => {
it('concurrent starts result in single process', async () => {
await remoteExec('unraid-api start & unraid-api start & wait');
await new Promise((resolve) => setTimeout(resolve, 3000));
await waitForStart(10000);
await new Promise((resolve) => setTimeout(resolve, 3000));
const newPid = await getRemotePid();
expect(newPid).toBeTruthy();
await assertSingleApiInstance();
expect(initialPid).not.toBe(newPid);
expect(await pidFileExists()).toBe(true);
await assertSingleApiInstance();
});
it('works when API is not running', async () => {
await remoteExec('unraid-api restart');
await waitForStart(10000);
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(await isProcessRunning(pid)).toBe(true);
});
});
it('API recovers after process is killed externally', async () => {
await startApi();
describe('edge cases', () => {
it('concurrent starts result in single process', async () => {
await remoteExec('unraid-api start & unraid-api start & wait');
const pid = await getRemotePid();
expect(pid).toBeTruthy();
await new Promise((resolve) => setTimeout(resolve, 3000));
await remoteExec(`kill -9 '${pid}'`);
await assertSingleApiInstance();
await new Promise((resolve) => setTimeout(resolve, 1000));
expect(await pidFileExists()).toBe(true);
});
await startApi();
it('API recovers after process is killed externally', async () => {
await startApi();
const newPid = await getRemotePid();
expect(newPid).toBeTruthy();
const pid = await getRemotePid();
expect(pid).toBeTruthy();
expect(await isProcessRunning(newPid)).toBe(true);
await remoteExec(`kill -9 '${pid}'`);
await new Promise((resolve) => setTimeout(resolve, 1000));
await startApi();
const newPid = await getRemotePid();
expect(newPid).toBeTruthy();
expect(await isProcessRunning(newPid)).toBe(true);
});
});
});
});