test_book.sh 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # =============================================================================
  4. # test_book.sh — LLM App Tutorial (Ch1–Ch5) E2E Test
  5. #
  6. # Code is extracted from the ja doc markdown files (the code block after each
  7. # <summary data-file="..."> line; see extract_code.sh), so tests always stay
  8. # in sync with the docs.
  9. # =============================================================================
  10. SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
  11. PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
  12. DOCS_DIR="$PROJECT_ROOT/docs-src/pages/ja/llm-app"
  13. WORKDIR=$(mktemp -d)
  14. MODEL_NAME="gemma-2-2b-it-Q4_K_M.gguf"
  15. MODEL_URL="https://huggingface.co/bartowski/gemma-2-2b-it-GGUF/resolve/main/${MODEL_NAME}"
  16. PORT=18080
  17. GECKODRIVER_PORT=4444
  18. SERVER_PID=""
  19. GECKODRIVER_PID=""
  20. PASS_COUNT=0
  21. FAIL_COUNT=0
  22. # ---------------------------------------------------------------------------
  23. # Cleanup
  24. # ---------------------------------------------------------------------------
  25. cleanup() {
  26. if [[ -n "$SERVER_PID" ]]; then
  27. kill "$SERVER_PID" 2>/dev/null || true
  28. wait "$SERVER_PID" 2>/dev/null || true
  29. fi
  30. if [[ -n "$GECKODRIVER_PID" ]]; then
  31. kill "$GECKODRIVER_PID" 2>/dev/null || true
  32. wait "$GECKODRIVER_PID" 2>/dev/null || true
  33. fi
  34. rm -rf "$WORKDIR"
  35. }
  36. trap cleanup EXIT
  37. # ---------------------------------------------------------------------------
  38. # Helpers
  39. # ---------------------------------------------------------------------------
  40. log() { echo "=== $*"; }
  41. pass() { echo " PASS: $*"; PASS_COUNT=$((PASS_COUNT + 1)); }
  42. fail() { echo " FAIL: $*"; FAIL_COUNT=$((FAIL_COUNT + 1)); }
  43. source "$SCRIPT_DIR/extract_code.sh"
  44. wait_for_server() {
  45. local max_wait=30
  46. local i=0
  47. while ! curl -s -o /dev/null "http://127.0.0.1:${PORT}/" 2>/dev/null; do
  48. sleep 1
  49. i=$((i + 1))
  50. if [[ $i -ge $max_wait ]]; then
  51. fail "Server did not start within ${max_wait}s"
  52. return 1
  53. fi
  54. done
  55. }
  56. stop_server() {
  57. if [[ -n "$SERVER_PID" ]]; then
  58. kill "$SERVER_PID" 2>/dev/null || true
  59. wait "$SERVER_PID" 2>/dev/null || true
  60. SERVER_PID=""
  61. fi
  62. }
  63. # Make an HTTP request and capture status + body
  64. # Usage: http_request METHOD PATH [DATA]
  65. # Sets: HTTP_STATUS, HTTP_BODY
  66. http_request() {
  67. local method="$1" path="$2" data="${3:-}"
  68. local tmp
  69. tmp=$(mktemp)
  70. if [[ -n "$data" ]]; then
  71. HTTP_STATUS=$(curl -s -o "$tmp" -w '%{http_code}' \
  72. -X "$method" "http://127.0.0.1:${PORT}${path}" \
  73. -H "Content-Type: application/json" \
  74. -d "$data")
  75. else
  76. HTTP_STATUS=$(curl -s -o "$tmp" -w '%{http_code}' \
  77. -X "$method" "http://127.0.0.1:${PORT}${path}")
  78. fi
  79. HTTP_BODY=$(cat "$tmp")
  80. rm -f "$tmp"
  81. }
  82. # Make an SSE request and capture the raw stream
  83. # Usage: http_sse PATH DATA
  84. # Sets: HTTP_STATUS, HTTP_BODY
  85. http_sse() {
  86. local path="$1" data="$2"
  87. HTTP_SSE_FILE=$(mktemp)
  88. HTTP_STATUS=$(curl -s -N -o "$HTTP_SSE_FILE" -w '%{http_code}' \
  89. -X POST "http://127.0.0.1:${PORT}${path}" \
  90. -H "Content-Type: application/json" \
  91. -d "$data")
  92. HTTP_BODY=$(cat "$HTTP_SSE_FILE")
  93. rm -f "$HTTP_SSE_FILE"
  94. }
  95. assert_status() {
  96. local expected="$1" label="$2"
  97. if [[ "$HTTP_STATUS" == "$expected" ]]; then
  98. pass "$label (status=$HTTP_STATUS)"
  99. else
  100. fail "$label (expected=$expected, got=$HTTP_STATUS)"
  101. echo " body: $HTTP_BODY"
  102. fi
  103. }
  104. assert_json_field() {
  105. local field="$1" label="$2"
  106. if echo "$HTTP_BODY" | python3 -c "import sys,json; d=json.load(sys.stdin); assert '$field' in d" 2>/dev/null; then
  107. pass "$label (field '$field' exists)"
  108. else
  109. fail "$label (field '$field' missing in response)"
  110. echo " body: $HTTP_BODY"
  111. fi
  112. }
  113. assert_json_value() {
  114. local field="$1" expected="$2" label="$3"
  115. local actual
  116. actual=$(echo "$HTTP_BODY" | python3 -c "import sys,json; print(json.load(sys.stdin)['$field'])" 2>/dev/null || echo "")
  117. if [[ "$actual" == "$expected" ]]; then
  118. pass "$label ($field='$actual')"
  119. else
  120. fail "$label (expected $field='$expected', got='$actual')"
  121. fi
  122. }
  123. assert_json_nonempty() {
  124. local field="$1" label="$2"
  125. local val
  126. 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 "")
  127. if [[ -n "$val" ]]; then
  128. pass "$label ($field is non-empty)"
  129. else
  130. fail "$label ($field is empty or missing)"
  131. echo " body: $HTTP_BODY"
  132. fi
  133. }
  134. # Patch port number in extracted source code (8080 -> test port)
  135. patch_port() {
  136. 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"
  137. }
  138. # Patch model path in extracted source code
  139. patch_model() {
  140. sed "s|models/gemma-2-2b-it-Q4_K_M.gguf|models/${MODEL_NAME}|g"
  141. }
  142. # =============================================================================
  143. # Ch1: Skeleton Server
  144. # =============================================================================
  145. test_ch1() {
  146. log "Ch1: Project Setup & Skeleton Server"
  147. local APP_DIR="$WORKDIR/translate-app"
  148. mkdir -p "$APP_DIR/src" "$APP_DIR/models"
  149. cd "$APP_DIR"
  150. # Copy httplib.h from project root (test current version)
  151. cp "$PROJECT_ROOT/httplib.h" .
  152. # Download json.hpp into nlohmann/ directory to match #include <nlohmann/json.hpp>
  153. mkdir -p nlohmann
  154. curl -sL -o nlohmann/json.hpp \
  155. https://github.com/nlohmann/json/releases/latest/download/json.hpp
  156. # CMakeLists.txt — ch1 doesn't need llama.cpp, so use a minimal version
  157. # (the doc's cmake includes llama.cpp which isn't cloned yet in ch1)
  158. cat > CMakeLists.txt << 'CMAKE_EOF'
  159. cmake_minimum_required(VERSION 3.16)
  160. project(translate-server LANGUAGES CXX)
  161. set(CMAKE_CXX_STANDARD 17)
  162. add_executable(translate-server src/main.cpp)
  163. target_include_directories(translate-server PRIVATE ${CMAKE_SOURCE_DIR})
  164. CMAKE_EOF
  165. # Extract main.cpp from ch1 doc and patch port
  166. extract_code "$DOCS_DIR/ch01-setup.md" "main.cpp" | patch_port > src/main.cpp
  167. # Build
  168. log "Ch1: Building..."
  169. cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
  170. cmake --build build -j 2>&1 | tail -3
  171. # Start server
  172. ./build/translate-server &
  173. SERVER_PID=$!
  174. wait_for_server
  175. # Tests
  176. http_request POST /translate '{"text":"hello","target_lang":"ja"}'
  177. assert_status 200 "Ch1 POST /translate"
  178. assert_json_value translation "TODO" "Ch1 POST /translate returns TODO"
  179. http_request GET /models
  180. assert_status 200 "Ch1 GET /models"
  181. assert_json_field models "Ch1 GET /models"
  182. http_request POST /models/select '{"model":"test"}'
  183. assert_status 200 "Ch1 POST /models/select"
  184. assert_json_value status "TODO" "Ch1 POST /models/select returns TODO"
  185. stop_server
  186. log "Ch1: Done"
  187. }
  188. # =============================================================================
  189. # Ch2: REST API with llama.cpp
  190. # =============================================================================
  191. test_ch2() {
  192. log "Ch2: REST API with llama.cpp"
  193. local APP_DIR="$WORKDIR/translate-app"
  194. cd "$APP_DIR"
  195. # Clone llama.cpp
  196. if [[ ! -d llama.cpp ]]; then
  197. log "Ch2: Cloning llama.cpp..."
  198. git clone --depth 1 https://github.com/ggml-org/llama.cpp.git 2>&1 | tail -1
  199. fi
  200. # Download cpp-llamalib.h
  201. if [[ ! -f cpp-llamalib.h ]]; then
  202. curl -sL -o cpp-llamalib.h \
  203. https://raw.githubusercontent.com/yhirose/cpp-llamalib/main/cpp-llamalib.h
  204. fi
  205. # Download model
  206. if [[ ! -f "models/$MODEL_NAME" ]]; then
  207. log "Ch2: Downloading model ${MODEL_NAME} (~1.6GB)..."
  208. curl -L -o "models/$MODEL_NAME" "$MODEL_URL"
  209. fi
  210. # CMakeLists.txt from ch1 doc (includes llama.cpp)
  211. extract_code "$DOCS_DIR/ch01-setup.md" "CMakeLists.txt" > CMakeLists.txt
  212. # Extract main.cpp from ch2 doc and patch port + model path
  213. extract_code "$DOCS_DIR/ch02-rest-api.md" "main.cpp" | patch_port | patch_model > src/main.cpp
  214. # Build (clean rebuild needed — cmake config changed)
  215. log "Ch2: Building (this may take a while for llama.cpp)..."
  216. rm -rf build
  217. cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
  218. cmake --build build -j 2>&1 | tail -3
  219. # Start server
  220. ./build/translate-server &
  221. SERVER_PID=$!
  222. wait_for_server
  223. # Tests — normal request
  224. http_request POST /translate \
  225. '{"text":"I had a great time visiting Tokyo last spring. The cherry blossoms were beautiful.","target_lang":"ja"}'
  226. assert_status 200 "Ch2 POST /translate normal"
  227. assert_json_nonempty translation "Ch2 POST /translate has translation"
  228. # Tests — invalid JSON
  229. http_request POST /translate 'not json'
  230. assert_status 400 "Ch2 POST /translate invalid JSON"
  231. # Tests — missing text
  232. http_request POST /translate '{"target_lang":"ja"}'
  233. assert_status 400 "Ch2 POST /translate missing text"
  234. # Tests — empty text
  235. http_request POST /translate '{"text":""}'
  236. assert_status 400 "Ch2 POST /translate empty text"
  237. stop_server
  238. log "Ch2: Done"
  239. }
  240. # =============================================================================
  241. # Ch3: SSE Streaming
  242. # =============================================================================
  243. test_ch3() {
  244. log "Ch3: SSE Streaming"
  245. local APP_DIR="$WORKDIR/translate-app"
  246. cd "$APP_DIR"
  247. # Extract main.cpp from ch3 doc and patch port + model path
  248. extract_code "$DOCS_DIR/ch03-sse-streaming.md" "main.cpp" | patch_port | patch_model > src/main.cpp
  249. # Build (incremental — only main.cpp changed)
  250. log "Ch3: Building..."
  251. cmake --build build -j 2>&1 | tail -3
  252. # Start server
  253. ./build/translate-server &
  254. SERVER_PID=$!
  255. wait_for_server
  256. # Tests — /translate still works
  257. http_request POST /translate \
  258. '{"text":"Hello world","target_lang":"ja"}'
  259. assert_status 200 "Ch3 POST /translate still works"
  260. # Tests — SSE streaming
  261. http_sse /translate/stream \
  262. '{"text":"I had a great time visiting Tokyo last spring. The cherry blossoms were beautiful.","target_lang":"ja"}'
  263. assert_status 200 "Ch3 POST /translate/stream status"
  264. # Check SSE format: has data: lines and ends with [DONE]
  265. local data_lines
  266. data_lines=$(echo "$HTTP_BODY" | grep -c '^data: ' || true)
  267. if [[ $data_lines -ge 2 ]]; then
  268. pass "Ch3 SSE has multiple data: lines ($data_lines)"
  269. else
  270. fail "Ch3 SSE expected multiple data: lines, got $data_lines"
  271. echo " body: $HTTP_BODY"
  272. fi
  273. if echo "$HTTP_BODY" | grep -q 'data: \[DONE\]'; then
  274. pass "Ch3 SSE ends with data: [DONE]"
  275. else
  276. fail "Ch3 SSE missing data: [DONE]"
  277. echo " body: $HTTP_BODY"
  278. fi
  279. # Tests — SSE invalid JSON
  280. http_sse /translate/stream 'not json'
  281. assert_status 400 "Ch3 POST /translate/stream invalid JSON"
  282. stop_server
  283. log "Ch3: Done"
  284. }
  285. # =============================================================================
  286. # Ch4: Model Management
  287. # =============================================================================
  288. test_ch4() {
  289. log "Ch4: Model Management"
  290. local APP_DIR="$WORKDIR/translate-app"
  291. cd "$APP_DIR"
  292. # Ch4+ uses ~/.translate-app/models/ — symlink model there
  293. local MODELS_HOME="$HOME/.translate-app/models"
  294. mkdir -p "$MODELS_HOME"
  295. ln -sf "$APP_DIR/models/$MODEL_NAME" "$MODELS_HOME/$MODEL_NAME"
  296. # CMakeLists.txt from ch4 (adds OpenSSL)
  297. extract_code "$DOCS_DIR/ch04-model-management.md" "CMakeLists.txt" > CMakeLists.txt
  298. # Extract main.cpp from ch4 doc
  299. extract_code "$DOCS_DIR/ch04-model-management.md" "main.cpp" | patch_port > src/main.cpp
  300. # Build (reconfigure for OpenSSL, incremental — reuses llama.cpp objects)
  301. log "Ch4: Building..."
  302. cmake -B build -DCMAKE_BUILD_TYPE=Release 2>&1 | tail -1
  303. cmake --build build -j 2>&1 | tail -3
  304. # Start server
  305. ./build/translate-server &
  306. SERVER_PID=$!
  307. wait_for_server
  308. # Tests — GET /models
  309. http_request GET /models
  310. assert_status 200 "Ch4 GET /models"
  311. assert_json_field models "Ch4 GET /models has models array"
  312. # デフォルトモデルがdownloaded+selectedであること
  313. local selected
  314. selected=$(echo "$HTTP_BODY" | python3 -c "
  315. import sys, json
  316. models = json.load(sys.stdin)['models']
  317. sel = [m for m in models if m['selected']]
  318. print(sel[0]['downloaded'] if sel else '')
  319. " 2>/dev/null || echo "")
  320. if [[ "$selected" == "True" ]]; then
  321. pass "Ch4 GET /models default model is downloaded and selected"
  322. else
  323. fail "Ch4 GET /models default model state unexpected"
  324. echo " body: $HTTP_BODY"
  325. fi
  326. # Tests — POST /models/select with already-downloaded model (SSE)
  327. http_sse /models/select '{"model": "gemma-2-2b-it"}'
  328. assert_status 200 "Ch4 POST /models/select already downloaded"
  329. if echo "$HTTP_BODY" | grep -q '"ready"'; then
  330. pass "Ch4 POST /models/select returns ready"
  331. else
  332. fail "Ch4 POST /models/select missing ready status"
  333. echo " body: $HTTP_BODY"
  334. fi
  335. # Tests — POST /models/select unknown model
  336. http_request POST /models/select '{"model": "nonexistent"}'
  337. assert_status 404 "Ch4 POST /models/select unknown model"
  338. # Tests — POST /models/select missing model field
  339. http_request POST /models/select '{"foo": "bar"}'
  340. assert_status 400 "Ch4 POST /models/select missing model field"
  341. # Tests — /translate still works after model select
  342. http_request POST /translate '{"text": "Hello", "target_lang": "ja"}'
  343. assert_status 200 "Ch4 POST /translate still works"
  344. assert_json_nonempty translation "Ch4 POST /translate has translation"
  345. # Tests — switch model via symlink (avoids downloading a second model)
  346. # Place a symlink so the server sees Llama-3.1-8B-Instruct as "downloaded"
  347. ln -sf "$MODELS_HOME/$MODEL_NAME" "$MODELS_HOME/Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf"
  348. http_sse /models/select '{"model": "Llama-3.1-8B-Instruct"}'
  349. assert_status 200 "Ch4 POST /models/select switch model"
  350. if echo "$HTTP_BODY" | grep -q '"ready"'; then
  351. pass "Ch4 model switch returns ready"
  352. else
  353. fail "Ch4 model switch missing ready"
  354. echo " body: $HTTP_BODY"
  355. fi
  356. # Translate with the switched model
  357. http_request POST /translate \
  358. '{"text": "The weather is nice today.", "target_lang": "ja"}'
  359. assert_status 200 "Ch4 POST /translate after model switch"
  360. assert_json_nonempty translation "Ch4 POST /translate switched model has translation"
  361. # Verify model list reflects the switch
  362. http_request GET /models
  363. local new_selected
  364. new_selected=$(echo "$HTTP_BODY" | python3 -c "
  365. import sys, json
  366. models = json.load(sys.stdin)['models']
  367. sel = [m for m in models if m['selected']]
  368. print(sel[0]['name'] if sel else '')
  369. " 2>/dev/null || echo "")
  370. if [[ "$new_selected" == "Llama-3.1-8B-Instruct" ]]; then
  371. pass "Ch4 GET /models reflects model switch"
  372. else
  373. fail "Ch4 GET /models expected Llama-3.1-8B-Instruct selected, got '$new_selected'"
  374. fi
  375. stop_server
  376. log "Ch4: Done"
  377. }
  378. # =============================================================================
  379. # Ch5: Web UI (browser tests via geckodriver + webdriver.h)
  380. # =============================================================================
  381. start_geckodriver() {
  382. geckodriver --port "$GECKODRIVER_PORT" &>/dev/null &
  383. GECKODRIVER_PID=$!
  384. # Wait for geckodriver to be ready
  385. local i=0
  386. while ! curl -s -o /dev/null "http://127.0.0.1:${GECKODRIVER_PORT}/status" 2>/dev/null; do
  387. sleep 0.5
  388. i=$((i + 1))
  389. if [[ $i -ge 20 ]]; then
  390. fail "geckodriver did not start within 10s"
  391. return 1
  392. fi
  393. done
  394. }
  395. stop_geckodriver() {
  396. if [[ -n "$GECKODRIVER_PID" ]]; then
  397. kill "$GECKODRIVER_PID" 2>/dev/null || true
  398. wait "$GECKODRIVER_PID" 2>/dev/null || true
  399. GECKODRIVER_PID=""
  400. fi
  401. }
  402. test_ch5() {
  403. log "Ch5: Web UI (browser tests)"
  404. # Check for geckodriver
  405. if ! command -v geckodriver &>/dev/null; then
  406. log "Ch5: Skipping browser tests (geckodriver not found)"
  407. log "Ch5: Install with: brew install geckodriver"
  408. return 0
  409. fi
  410. local APP_DIR="$WORKDIR/translate-app"
  411. cd "$APP_DIR"
  412. # Extract source files from ch05
  413. extract_code "$DOCS_DIR/ch05-web-ui.md" "main.cpp" \
  414. | patch_port > src/main.cpp
  415. mkdir -p public
  416. extract_code "$DOCS_DIR/ch05-web-ui.md" "index.html" > public/index.html
  417. extract_code "$DOCS_DIR/ch05-web-ui.md" "style.css" > public/style.css
  418. extract_code "$DOCS_DIR/ch05-web-ui.md" "script.js" > public/script.js
  419. # Build (incremental — only main.cpp changed)
  420. log "Ch5: Building server..."
  421. cmake --build build -j 2>&1 | tail -3
  422. # Build browser test program
  423. log "Ch5: Building browser test..."
  424. g++ -std=c++17 \
  425. -I"$APP_DIR" \
  426. -I"$SCRIPT_DIR" \
  427. -o "$APP_DIR/build/test_webui" \
  428. "$SCRIPT_DIR/test_webui.cpp" \
  429. -pthread
  430. # Start server
  431. ./build/translate-server &
  432. SERVER_PID=$!
  433. wait_for_server
  434. # Start geckodriver
  435. start_geckodriver
  436. # Run browser tests
  437. log "Ch5: Running browser tests..."
  438. local test_exit=0
  439. "$APP_DIR/build/test_webui" "$PORT" || test_exit=$?
  440. # Parse pass/fail from test output and add to totals
  441. # (test_webui prints its own pass/fail, but we track via exit code)
  442. if [[ $test_exit -ne 0 ]]; then
  443. fail "Ch5 browser tests had failures"
  444. else
  445. pass "Ch5 browser tests all passed"
  446. fi
  447. stop_geckodriver
  448. stop_server
  449. log "Ch5: Done"
  450. }
  451. # =============================================================================
  452. # Main
  453. # =============================================================================
  454. log "LLM App Tutorial E2E Test"
  455. log "Working directory: $WORKDIR"
  456. echo ""
  457. test_ch1
  458. echo ""
  459. test_ch2
  460. echo ""
  461. test_ch3
  462. echo ""
  463. test_ch4
  464. echo ""
  465. test_ch5
  466. log "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
  467. if [[ $FAIL_COUNT -gt 0 ]]; then
  468. exit 1
  469. fi