mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-04-11 11:18:30 +00:00
562 lines
17 KiB
Bash
Executable File
562 lines
17 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
|
||
# =============================================================================
|
||
# test_book.sh — LLM App Tutorial (Ch1–Ch5) E2E Test
|
||
#
|
||
# Code is extracted from the doc markdown files (<!-- test:full-code --> and
|
||
# <!-- test:cmake --> markers), so tests always stay in sync with the docs.
|
||
# =============================================================================
|
||
|
||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||
DOCS_DIR="$PROJECT_ROOT/docs-src/pages/ja/llm-app"
|
||
WORKDIR=$(mktemp -d)
|
||
MODEL_NAME="gemma-2-2b-it-Q4_K_M.gguf"
|
||
MODEL_URL="https://huggingface.co/bartowski/gemma-2-2b-it-GGUF/resolve/main/${MODEL_NAME}"
|
||
PORT=18080
|
||
GECKODRIVER_PORT=4444
|
||
SERVER_PID=""
|
||
GECKODRIVER_PID=""
|
||
PASS_COUNT=0
|
||
FAIL_COUNT=0
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Cleanup
|
||
# ---------------------------------------------------------------------------
|
||
cleanup() {
|
||
if [[ -n "$SERVER_PID" ]]; then
|
||
kill "$SERVER_PID" 2>/dev/null || true
|
||
wait "$SERVER_PID" 2>/dev/null || true
|
||
fi
|
||
if [[ -n "$GECKODRIVER_PID" ]]; then
|
||
kill "$GECKODRIVER_PID" 2>/dev/null || true
|
||
wait "$GECKODRIVER_PID" 2>/dev/null || true
|
||
fi
|
||
rm -rf "$WORKDIR"
|
||
}
|
||
trap cleanup EXIT
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers
|
||
# ---------------------------------------------------------------------------
|
||
log() { echo "=== $*"; }
|
||
pass() { echo " PASS: $*"; PASS_COUNT=$((PASS_COUNT + 1)); }
|
||
fail() { echo " FAIL: $*"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
|
||
|
||
source "$SCRIPT_DIR/extract_code.sh"
|
||
|
||
wait_for_server() {
|
||
local max_wait=30
|
||
local i=0
|
||
while ! curl -s -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; do
|
||
sleep 1
|
||
i=$((i + 1))
|
||
if [[ $i -ge $max_wait ]]; then
|
||
fail "Server did not start within ${max_wait}s"
|
||
return 1
|
||
fi
|
||
done
|
||
}
|
||
|
||
stop_server() {
|
||
if [[ -n "$SERVER_PID" ]]; then
|
||
kill "$SERVER_PID" 2>/dev/null || true
|
||
wait "$SERVER_PID" 2>/dev/null || true
|
||
SERVER_PID=""
|
||
fi
|
||
}
|
||
|
||
# Make an HTTP request and capture status + body
|
||
# Usage: http_request METHOD PATH [DATA]
|
||
# Sets: HTTP_STATUS, HTTP_BODY
|
||
http_request() {
|
||
local method="$1" path="$2" data="${3:-}"
|
||
local tmp
|
||
tmp=$(mktemp)
|
||
if [[ -n "$data" ]]; then
|
||
HTTP_STATUS=$(curl -s -o "$tmp" -w '%{http_code}' \
|
||
-X "$method" "http://127.0.0.1:${PORT}${path}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$data")
|
||
else
|
||
HTTP_STATUS=$(curl -s -o "$tmp" -w '%{http_code}' \
|
||
-X "$method" "http://127.0.0.1:${PORT}${path}")
|
||
fi
|
||
HTTP_BODY=$(cat "$tmp")
|
||
rm -f "$tmp"
|
||
}
|
||
|
||
# Make an SSE request and capture the raw stream
|
||
# Usage: http_sse PATH DATA
|
||
# Sets: HTTP_STATUS, HTTP_BODY
|
||
http_sse() {
|
||
local path="$1" data="$2"
|
||
HTTP_SSE_FILE=$(mktemp)
|
||
HTTP_STATUS=$(curl -s -N -o "$HTTP_SSE_FILE" -w '%{http_code}' \
|
||
-X POST "http://127.0.0.1:${PORT}${path}" \
|
||
-H "Content-Type: application/json" \
|
||
-d "$data")
|
||
HTTP_BODY=$(cat "$HTTP_SSE_FILE")
|
||
rm -f "$HTTP_SSE_FILE"
|
||
}
|
||
|
||
assert_status() {
|
||
local expected="$1" label="$2"
|
||
if [[ "$HTTP_STATUS" == "$expected" ]]; then
|
||
pass "$label (status=$HTTP_STATUS)"
|
||
else
|
||
fail "$label (expected=$expected, got=$HTTP_STATUS)"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
}
|
||
|
||
assert_json_field() {
|
||
local field="$1" label="$2"
|
||
if echo "$HTTP_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$field' in d" 2>/dev/null; then
|
||
pass "$label (field '$field' exists)"
|
||
else
|
||
fail "$label (field '$field' missing in response)"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
}
|
||
|
||
assert_json_value() {
|
||
local field="$1" expected="$2" label="$3"
|
||
local actual
|
||
actual=$(echo "$HTTP_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['$field'])" 2>/dev/null || echo "")
|
||
if [[ "$actual" == "$expected" ]]; then
|
||
pass "$label ($field='$actual')"
|
||
else
|
||
fail "$label (expected $field='$expected', got='$actual')"
|
||
fi
|
||
}
|
||
|
||
assert_json_nonempty() {
|
||
local field="$1" label="$2"
|
||
local val
|
||
val=$(echo "$HTTP_BODY" | python3 -c "import sys,json; v=json.load(sys.stdin)['$field']; assert len(str(v))>0; print(v)" 2>/dev/null || echo "")
|
||
if [[ -n "$val" ]]; then
|
||
pass "$label ($field is non-empty)"
|
||
else
|
||
fail "$label ($field is empty or missing)"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
}
|
||
|
||
# Patch port number in extracted source code (8080 -> test port)
|
||
patch_port() {
|
||
sed "s/127\.0\.0\.1\", 8080/127.0.0.1\", ${PORT}/g; s/127\.0\.0\.1:8080/127.0.0.1:${PORT}/g"
|
||
}
|
||
|
||
# Patch model path in extracted source code
|
||
patch_model() {
|
||
sed "s|models/gemma-2-2b-it-Q4_K_M.gguf|models/${MODEL_NAME}|g"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Ch1: Skeleton Server
|
||
# =============================================================================
|
||
test_ch1() {
|
||
log "Ch1: Project Setup & Skeleton Server"
|
||
|
||
local APP_DIR="$WORKDIR/translate-app"
|
||
mkdir -p "$APP_DIR/src" "$APP_DIR/models"
|
||
cd "$APP_DIR"
|
||
|
||
# Copy httplib.h from project root (test current version)
|
||
cp "$PROJECT_ROOT/httplib.h" .
|
||
|
||
# Download json.hpp into nlohmann/ directory to match #include <nlohmann/json.hpp>
|
||
mkdir -p nlohmann
|
||
curl -sL -o nlohmann/json.hpp \
|
||
https://github.com/nlohmann/json/releases/latest/download/json.hpp
|
||
|
||
# CMakeLists.txt — ch1 doesn't need llama.cpp, so use a minimal version
|
||
# (the doc's cmake includes llama.cpp which isn't cloned yet in ch1)
|
||
cat > CMakeLists.txt << 'CMAKE_EOF'
|
||
cmake_minimum_required(VERSION 3.16)
|
||
project(translate-server LANGUAGES CXX)
|
||
set(CMAKE_CXX_STANDARD 17)
|
||
|
||
add_executable(translate-server src/main.cpp)
|
||
target_include_directories(translate-server PRIVATE ${CMAKE_SOURCE_DIR})
|
||
CMAKE_EOF
|
||
|
||
# Extract main.cpp from ch1 doc and patch port
|
||
extract_code "$DOCS_DIR/ch01-setup.md" "main.cpp" | patch_port > src/main.cpp
|
||
|
||
# Build
|
||
log "Ch1: Building..."
|
||
cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
|
||
cmake --build build -j 2>&1 | tail -3
|
||
|
||
# Start server
|
||
./build/translate-server &
|
||
SERVER_PID=$!
|
||
wait_for_server
|
||
|
||
# Tests
|
||
http_request POST /translate '{"text":"hello","target_lang":"ja"}'
|
||
assert_status 200 "Ch1 POST /translate"
|
||
assert_json_value translation "TODO" "Ch1 POST /translate returns TODO"
|
||
|
||
http_request GET /models
|
||
assert_status 200 "Ch1 GET /models"
|
||
assert_json_field models "Ch1 GET /models"
|
||
|
||
http_request POST /models/select '{"model":"test"}'
|
||
assert_status 200 "Ch1 POST /models/select"
|
||
assert_json_value status "TODO" "Ch1 POST /models/select returns TODO"
|
||
|
||
stop_server
|
||
log "Ch1: Done"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Ch2: REST API with llama.cpp
|
||
# =============================================================================
|
||
test_ch2() {
|
||
log "Ch2: REST API with llama.cpp"
|
||
|
||
local APP_DIR="$WORKDIR/translate-app"
|
||
cd "$APP_DIR"
|
||
|
||
# Clone llama.cpp
|
||
if [[ ! -d llama.cpp ]]; then
|
||
log "Ch2: Cloning llama.cpp..."
|
||
git clone --depth 1 https://github.com/ggml-org/llama.cpp.git 2>&1 | tail -1
|
||
fi
|
||
|
||
# Download cpp-llamalib.h
|
||
if [[ ! -f cpp-llamalib.h ]]; then
|
||
curl -sL -o cpp-llamalib.h \
|
||
https://raw.githubusercontent.com/yhirose/cpp-llamalib/main/cpp-llamalib.h
|
||
fi
|
||
|
||
# Download model
|
||
if [[ ! -f "models/$MODEL_NAME" ]]; then
|
||
log "Ch2: Downloading model ${MODEL_NAME} (~1.6GB)..."
|
||
curl -L -o "models/$MODEL_NAME" "$MODEL_URL"
|
||
fi
|
||
|
||
# CMakeLists.txt from ch1 doc (includes llama.cpp)
|
||
extract_code "$DOCS_DIR/ch01-setup.md" "CMakeLists.txt" > CMakeLists.txt
|
||
|
||
# Extract main.cpp from ch2 doc and patch port + model path
|
||
extract_code "$DOCS_DIR/ch02-rest-api.md" "main.cpp" | patch_port | patch_model > src/main.cpp
|
||
|
||
# Build (clean rebuild needed — cmake config changed)
|
||
log "Ch2: Building (this may take a while for llama.cpp)..."
|
||
rm -rf build
|
||
cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
|
||
cmake --build build -j 2>&1 | tail -3
|
||
|
||
# Start server
|
||
./build/translate-server &
|
||
SERVER_PID=$!
|
||
wait_for_server
|
||
|
||
# Tests — normal request
|
||
http_request POST /translate \
|
||
'{"text":"I had a great time visiting Tokyo last spring. The cherry blossoms were beautiful.","target_lang":"ja"}'
|
||
assert_status 200 "Ch2 POST /translate normal"
|
||
assert_json_nonempty translation "Ch2 POST /translate has translation"
|
||
|
||
# Tests — invalid JSON
|
||
http_request POST /translate 'not json'
|
||
assert_status 400 "Ch2 POST /translate invalid JSON"
|
||
|
||
# Tests — missing text
|
||
http_request POST /translate '{"target_lang":"ja"}'
|
||
assert_status 400 "Ch2 POST /translate missing text"
|
||
|
||
# Tests — empty text
|
||
http_request POST /translate '{"text":""}'
|
||
assert_status 400 "Ch2 POST /translate empty text"
|
||
|
||
stop_server
|
||
log "Ch2: Done"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Ch3: SSE Streaming
|
||
# =============================================================================
|
||
test_ch3() {
|
||
log "Ch3: SSE Streaming"
|
||
|
||
local APP_DIR="$WORKDIR/translate-app"
|
||
cd "$APP_DIR"
|
||
|
||
# Extract main.cpp from ch3 doc and patch port + model path
|
||
extract_code "$DOCS_DIR/ch03-sse-streaming.md" "main.cpp" | patch_port | patch_model > src/main.cpp
|
||
|
||
# Build (incremental — only main.cpp changed)
|
||
log "Ch3: Building..."
|
||
cmake --build build -j 2>&1 | tail -3
|
||
|
||
# Start server
|
||
./build/translate-server &
|
||
SERVER_PID=$!
|
||
wait_for_server
|
||
|
||
# Tests — /translate still works
|
||
http_request POST /translate \
|
||
'{"text":"Hello world","target_lang":"ja"}'
|
||
assert_status 200 "Ch3 POST /translate still works"
|
||
|
||
# Tests — SSE streaming
|
||
http_sse /translate/stream \
|
||
'{"text":"I had a great time visiting Tokyo last spring. The cherry blossoms were beautiful.","target_lang":"ja"}'
|
||
assert_status 200 "Ch3 POST /translate/stream status"
|
||
|
||
# Check SSE format: has data: lines and ends with [DONE]
|
||
local data_lines
|
||
data_lines=$(echo "$HTTP_BODY" | grep -c '^data: ' || true)
|
||
if [[ $data_lines -ge 2 ]]; then
|
||
pass "Ch3 SSE has multiple data: lines ($data_lines)"
|
||
else
|
||
fail "Ch3 SSE expected multiple data: lines, got $data_lines"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
|
||
if echo "$HTTP_BODY" | grep -q 'data: \[DONE\]'; then
|
||
pass "Ch3 SSE ends with data: [DONE]"
|
||
else
|
||
fail "Ch3 SSE missing data: [DONE]"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
|
||
# Tests — SSE invalid JSON
|
||
http_sse /translate/stream 'not json'
|
||
assert_status 400 "Ch3 POST /translate/stream invalid JSON"
|
||
|
||
stop_server
|
||
log "Ch3: Done"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Ch4: Model Management
|
||
# =============================================================================
|
||
test_ch4() {
|
||
log "Ch4: Model Management"
|
||
|
||
local APP_DIR="$WORKDIR/translate-app"
|
||
cd "$APP_DIR"
|
||
|
||
# Ch4+ uses ~/.translate-app/models/ — symlink model there
|
||
local MODELS_HOME="$HOME/.translate-app/models"
|
||
mkdir -p "$MODELS_HOME"
|
||
ln -sf "$APP_DIR/models/$MODEL_NAME" "$MODELS_HOME/$MODEL_NAME"
|
||
|
||
# CMakeLists.txt from ch4 (adds OpenSSL)
|
||
extract_code "$DOCS_DIR/ch04-model-management.md" "CMakeLists.txt" > CMakeLists.txt
|
||
|
||
# Extract main.cpp from ch4 doc
|
||
extract_code "$DOCS_DIR/ch04-model-management.md" "main.cpp" | patch_port > src/main.cpp
|
||
|
||
# Build (reconfigure for OpenSSL, incremental — reuses llama.cpp objects)
|
||
log "Ch4: Building..."
|
||
cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
|
||
cmake --build build -j 2>&1 | tail -3
|
||
|
||
# Start server
|
||
./build/translate-server &
|
||
SERVER_PID=$!
|
||
wait_for_server
|
||
|
||
# Tests — GET /models
|
||
http_request GET /models
|
||
assert_status 200 "Ch4 GET /models"
|
||
assert_json_field models "Ch4 GET /models has models array"
|
||
|
||
# デフォルトモデルがdownloaded+selectedであること
|
||
local selected
|
||
selected=$(echo "$HTTP_BODY" | python3 -c "
|
||
import sys, json
|
||
models = json.load(sys.stdin)['models']
|
||
sel = [m for m in models if m['selected']]
|
||
print(sel[0]['downloaded'] if sel else '')
|
||
" 2>/dev/null || echo "")
|
||
if [[ "$selected" == "True" ]]; then
|
||
pass "Ch4 GET /models default model is downloaded and selected"
|
||
else
|
||
fail "Ch4 GET /models default model state unexpected"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
|
||
# Tests — POST /models/select with already-downloaded model (SSE)
|
||
http_sse /models/select '{"model": "gemma-2-2b-it"}'
|
||
assert_status 200 "Ch4 POST /models/select already downloaded"
|
||
|
||
if echo "$HTTP_BODY" | grep -q '"ready"'; then
|
||
pass "Ch4 POST /models/select returns ready"
|
||
else
|
||
fail "Ch4 POST /models/select missing ready status"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
|
||
# Tests — POST /models/select unknown model
|
||
http_request POST /models/select '{"model": "nonexistent"}'
|
||
assert_status 404 "Ch4 POST /models/select unknown model"
|
||
|
||
# Tests — POST /models/select missing model field
|
||
http_request POST /models/select '{"foo": "bar"}'
|
||
assert_status 400 "Ch4 POST /models/select missing model field"
|
||
|
||
# Tests — /translate still works after model select
|
||
http_request POST /translate '{"text": "Hello", "target_lang": "ja"}'
|
||
assert_status 200 "Ch4 POST /translate still works"
|
||
assert_json_nonempty translation "Ch4 POST /translate has translation"
|
||
|
||
# Tests — switch model via symlink (avoids downloading a second model)
|
||
# Place a symlink so the server sees Llama-3.1-8B-Instruct as "downloaded"
|
||
ln -sf "$MODELS_HOME/$MODEL_NAME" "$MODELS_HOME/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
|
||
|
||
http_sse /models/select '{"model": "Llama-3.1-8B-Instruct"}'
|
||
assert_status 200 "Ch4 POST /models/select switch model"
|
||
|
||
if echo "$HTTP_BODY" | grep -q '"ready"'; then
|
||
pass "Ch4 model switch returns ready"
|
||
else
|
||
fail "Ch4 model switch missing ready"
|
||
echo " body: $HTTP_BODY"
|
||
fi
|
||
|
||
# Translate with the switched model
|
||
http_request POST /translate \
|
||
'{"text": "The weather is nice today.", "target_lang": "ja"}'
|
||
assert_status 200 "Ch4 POST /translate after model switch"
|
||
assert_json_nonempty translation "Ch4 POST /translate switched model has translation"
|
||
|
||
# Verify model list reflects the switch
|
||
http_request GET /models
|
||
local new_selected
|
||
new_selected=$(echo "$HTTP_BODY" | python3 -c "
|
||
import sys, json
|
||
models = json.load(sys.stdin)['models']
|
||
sel = [m for m in models if m['selected']]
|
||
print(sel[0]['name'] if sel else '')
|
||
" 2>/dev/null || echo "")
|
||
if [[ "$new_selected" == "Llama-3.1-8B-Instruct" ]]; then
|
||
pass "Ch4 GET /models reflects model switch"
|
||
else
|
||
fail "Ch4 GET /models expected Llama-3.1-8B-Instruct selected, got '$new_selected'"
|
||
fi
|
||
|
||
stop_server
|
||
log "Ch4: Done"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Ch5: Web UI (browser tests via geckodriver + webdriver.h)
|
||
# =============================================================================
|
||
|
||
start_geckodriver() {
|
||
geckodriver --port "$GECKODRIVER_PORT" &>/dev/null &
|
||
GECKODRIVER_PID=$!
|
||
# Wait for geckodriver to be ready
|
||
local i=0
|
||
while ! curl -s -o /dev/null "http://127.0.0.1:${GECKODRIVER_PORT}/status" 2>/dev/null; do
|
||
sleep 0.5
|
||
i=$((i + 1))
|
||
if [[ $i -ge 20 ]]; then
|
||
fail "geckodriver did not start within 10s"
|
||
return 1
|
||
fi
|
||
done
|
||
}
|
||
|
||
stop_geckodriver() {
|
||
if [[ -n "$GECKODRIVER_PID" ]]; then
|
||
kill "$GECKODRIVER_PID" 2>/dev/null || true
|
||
wait "$GECKODRIVER_PID" 2>/dev/null || true
|
||
GECKODRIVER_PID=""
|
||
fi
|
||
}
|
||
|
||
test_ch5() {
|
||
log "Ch5: Web UI (browser tests)"
|
||
|
||
# Check for geckodriver
|
||
if ! command -v geckodriver &>/dev/null; then
|
||
log "Ch5: Skipping browser tests (geckodriver not found)"
|
||
log "Ch5: Install with: brew install geckodriver"
|
||
return 0
|
||
fi
|
||
|
||
local APP_DIR="$WORKDIR/translate-app"
|
||
cd "$APP_DIR"
|
||
|
||
# Extract source files from ch05
|
||
extract_code "$DOCS_DIR/ch05-web-ui.md" "main.cpp" \
|
||
| patch_port > src/main.cpp
|
||
|
||
mkdir -p public
|
||
extract_code "$DOCS_DIR/ch05-web-ui.md" "index.html" > public/index.html
|
||
extract_code "$DOCS_DIR/ch05-web-ui.md" "style.css" > public/style.css
|
||
extract_code "$DOCS_DIR/ch05-web-ui.md" "script.js" > public/script.js
|
||
|
||
# Build (incremental — only main.cpp changed)
|
||
log "Ch5: Building server..."
|
||
cmake --build build -j 2>&1 | tail -3
|
||
|
||
# Build browser test program
|
||
log "Ch5: Building browser test..."
|
||
g++ -std=c++17 \
|
||
-I"$APP_DIR" \
|
||
-I"$SCRIPT_DIR" \
|
||
-o "$APP_DIR/build/test_webui" \
|
||
"$SCRIPT_DIR/test_webui.cpp" \
|
||
-pthread
|
||
|
||
# Start server
|
||
./build/translate-server &
|
||
SERVER_PID=$!
|
||
wait_for_server
|
||
|
||
# Start geckodriver
|
||
start_geckodriver
|
||
|
||
# Run browser tests
|
||
log "Ch5: Running browser tests..."
|
||
local test_exit=0
|
||
"$APP_DIR/build/test_webui" "$PORT" || test_exit=$?
|
||
|
||
# Parse pass/fail from test output and add to totals
|
||
# (test_webui prints its own pass/fail, but we track via exit code)
|
||
if [[ $test_exit -ne 0 ]]; then
|
||
fail "Ch5 browser tests had failures"
|
||
else
|
||
pass "Ch5 browser tests all passed"
|
||
fi
|
||
|
||
stop_geckodriver
|
||
stop_server
|
||
log "Ch5: Done"
|
||
}
|
||
|
||
# =============================================================================
|
||
# Main
|
||
# =============================================================================
|
||
|
||
log "LLM App Tutorial E2E Test"
|
||
log "Working directory: $WORKDIR"
|
||
echo ""
|
||
|
||
test_ch1
|
||
echo ""
|
||
test_ch2
|
||
echo ""
|
||
test_ch3
|
||
echo ""
|
||
test_ch4
|
||
echo ""
|
||
test_ch5
|
||
|
||
log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
|
||
|
||
if [[ $FAIL_COUNT -gt 0 ]]; then
|
||
exit 1
|
||
fi
|