mirror of
https://github.com/unraid/api.git
synced 2025-12-21 08:39:38 -06:00
test: create vitest based integration suite
This commit is contained in:
@@ -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
53
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -5,5 +5,4 @@ packages:
|
||||
- "./unraid-ui"
|
||||
- "./web"
|
||||
- "./packages/*"
|
||||
- "./tests/bats"
|
||||
- "./tests/system-integration"
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
11
tests/system-integration/.prettierrc.cjs
Normal file
11
tests/system-integration/.prettierrc.cjs
Normal 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,
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
19
tests/system-integration/eslint.config.ts
Normal file
19
tests/system-integration/eslint.config.ts
Normal 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/**/*'],
|
||||
}
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user