| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- #!/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
|