Files
cpp-httplib/docs-util/llm-app/test_book.sh

562 lines
17 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
# =============================================================================
# test_book.sh — LLM App Tutorial (Ch1Ch5) 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