mirror of
https://github.com/yhirose/cpp-httplib.git
synced 2026-04-11 19:28:30 +00:00
"Building a Desktop LLM App with cpp-httplib" (#2403)
This commit is contained in:
33
docs-util/llm-app/build_desktop_app.sh
Executable file
33
docs-util/llm-app/build_desktop_app.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT_DIR="$SCRIPT_DIR/build/desktop-app"
|
||||
DOCS_DIR="$SCRIPT_DIR/../../docs-src/pages/ja/llm-app"
|
||||
|
||||
source "$SCRIPT_DIR/extract_code.sh"
|
||||
|
||||
echo "=== Setting up Desktop App (Chapter 6) ==="
|
||||
|
||||
mkdir -p "$OUT_DIR"/{src,public}
|
||||
cd "$OUT_DIR"
|
||||
|
||||
# --- Extract source files from book ---
|
||||
echo "Extracting source from book..."
|
||||
CH05="$DOCS_DIR/ch05-web-ui.md"
|
||||
CH06="$DOCS_DIR/ch06-desktop-app.md"
|
||||
|
||||
extract_code "$CH06" "CMakeLists.txt" > CMakeLists.txt
|
||||
extract_code "$CH06" "main.cpp" > src/main.cpp
|
||||
extract_code "$CH05" "index.html" > public/index.html
|
||||
extract_code "$CH05" "style.css" > public/style.css
|
||||
extract_code "$CH05" "script.js" > public/script.js
|
||||
|
||||
# --- Build ---
|
||||
echo "Building..."
|
||||
cmake -B build 2>&1 | tail -1
|
||||
cmake --build build -j 2>&1 | tail -1
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Run: cd $OUT_DIR && ./build/translate-app"
|
||||
33
docs-util/llm-app/build_web_app.sh
Executable file
33
docs-util/llm-app/build_web_app.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT_DIR="$SCRIPT_DIR/build/web-app"
|
||||
DOCS_DIR="$SCRIPT_DIR/../../docs-src/pages/ja/llm-app"
|
||||
|
||||
source "$SCRIPT_DIR/extract_code.sh"
|
||||
|
||||
echo "=== Setting up Web App (Chapter 5) ==="
|
||||
|
||||
mkdir -p "$OUT_DIR"/{src,public}
|
||||
cd "$OUT_DIR"
|
||||
|
||||
# --- Extract source files from book ---
|
||||
echo "Extracting source from book..."
|
||||
CH04="$DOCS_DIR/ch04-model-management.md"
|
||||
CH05="$DOCS_DIR/ch05-web-ui.md"
|
||||
|
||||
extract_code "$CH04" "CMakeLists.txt" > CMakeLists.txt
|
||||
extract_code "$CH05" "main.cpp" > src/main.cpp
|
||||
extract_code "$CH05" "index.html" > public/index.html
|
||||
extract_code "$CH05" "style.css" > public/style.css
|
||||
extract_code "$CH05" "script.js" > public/script.js
|
||||
|
||||
# --- Build ---
|
||||
echo "Building..."
|
||||
cmake -B build 2>&1 | tail -1
|
||||
cmake --build build -j 2>&1 | tail -1
|
||||
|
||||
echo ""
|
||||
echo "=== Done ==="
|
||||
echo "Run: cd $OUT_DIR && ./build/translate-server"
|
||||
18
docs-util/llm-app/extract_code.sh
Normal file
18
docs-util/llm-app/extract_code.sh
Normal file
@@ -0,0 +1,18 @@
|
||||
# Extract code block from a <details> section identified by data-file attribute.
|
||||
# Usage: extract_code <file> <data-file>
|
||||
# Example: extract_code ch01.md "main.cpp"
|
||||
extract_code() {
|
||||
local file="$1" name="$2"
|
||||
local output
|
||||
output=$(awk -v name="$name" '
|
||||
$0 ~ "data-file=\"" name "\"" { found=1; next }
|
||||
found && /^```/ && !inside { inside=1; next }
|
||||
inside && /^```/ { exit }
|
||||
inside { print }
|
||||
' "$file")
|
||||
if [ -z "$output" ]; then
|
||||
echo "ERROR: extract_code: no match for data-file=\"$name\" in $file" >&2
|
||||
return 1
|
||||
fi
|
||||
printf '%s\n' "$output"
|
||||
}
|
||||
38
docs-util/llm-app/generate_desktop_app_project.sh
Executable file
38
docs-util/llm-app/generate_desktop_app_project.sh
Executable file
@@ -0,0 +1,38 @@
|
||||
#!/bin/bash
|
||||
# Generate the desktop app project by extracting source from the cpp-httplib book.
|
||||
# Usage: generate_desktop_app_project.sh <output-dir>
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
OUT_DIR="${1:?Usage: $0 <output-dir>}"
|
||||
|
||||
BASE_URL="https://raw.githubusercontent.com/yhirose/cpp-httplib/master/docs-src/pages/ja/llm-app"
|
||||
CACHE_DIR="$SCRIPT_DIR/.cache"
|
||||
|
||||
source "$SCRIPT_DIR/extract_code.sh"
|
||||
|
||||
# --- Helper: download markdown files (always fetch latest) ---
|
||||
fetch_md() {
|
||||
local name="$1"
|
||||
local path="$CACHE_DIR/$name"
|
||||
curl -sfL "$BASE_URL/$name" -o "$path" || { echo "ERROR: Failed to download $name" >&2; return 1; }
|
||||
echo "$path"
|
||||
}
|
||||
|
||||
# --- Main ---
|
||||
echo "=== Generating desktop app project ==="
|
||||
|
||||
mkdir -p "$CACHE_DIR" "$OUT_DIR/src" "$OUT_DIR/public"
|
||||
|
||||
CH05=$(fetch_md "ch05-web-ui.md")
|
||||
CH06=$(fetch_md "ch06-desktop-app.md")
|
||||
|
||||
echo "Extracting source files..."
|
||||
extract_code "$CH06" "CMakeLists.txt" > "$OUT_DIR/CMakeLists.txt"
|
||||
extract_code "$CH06" "main.cpp" > "$OUT_DIR/src/main.cpp"
|
||||
extract_code "$CH05" "index.html" > "$OUT_DIR/public/index.html"
|
||||
extract_code "$CH05" "style.css" > "$OUT_DIR/public/style.css"
|
||||
extract_code "$CH05" "script.js" > "$OUT_DIR/public/script.js"
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "Generated files in: $OUT_DIR"
|
||||
28
docs-util/llm-app/justfile
Normal file
28
docs-util/llm-app/justfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# List available targets
|
||||
default:
|
||||
@just --list
|
||||
|
||||
# Remove build artifacts
|
||||
clean:
|
||||
rm -rf build
|
||||
|
||||
# Test the Book
|
||||
test-book:
|
||||
bash test_book.sh
|
||||
|
||||
# Build Web App (Chapter 5)
|
||||
build-web-app:
|
||||
bash build_web_app.sh
|
||||
|
||||
# Stop any running server, then run Web App
|
||||
run-web-app: build-web-app
|
||||
-lsof -ti :8080 | xargs kill 2>/dev/null
|
||||
cd build/web-app && ./build/translate-server
|
||||
|
||||
# Build Desktop App (Chapter 6)
|
||||
build-desktop-app:
|
||||
bash build_desktop_app.sh
|
||||
|
||||
# Run Desktop App
|
||||
run-desktop-app: build-desktop-app
|
||||
cd build/desktop-app && ./build/translate-app
|
||||
561
docs-util/llm-app/test_book.sh
Executable file
561
docs-util/llm-app/test_book.sh
Executable file
@@ -0,0 +1,561 @@
|
||||
#!/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
|
||||
300
docs-util/llm-app/test_webui.cpp
Normal file
300
docs-util/llm-app/test_webui.cpp
Normal file
@@ -0,0 +1,300 @@
|
||||
// test_webui.cpp — Browser-based E2E tests for Ch5 Web UI.
|
||||
// Uses webdriver.h (cpp-httplib + json.hpp) to control headless Firefox.
|
||||
//
|
||||
// Usage: test_webui <port>
|
||||
// port: the translate-server port (e.g. 18080)
|
||||
|
||||
#include "webdriver.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <iostream>
|
||||
#include <string>
|
||||
|
||||
// ─── Test framework (minimal) ────────────────────────────────
|
||||
|
||||
static int pass_count = 0;
|
||||
static int fail_count = 0;
|
||||
|
||||
#define PASS(label) \
|
||||
do { \
|
||||
std::cout << " PASS: " << (label) << "\n"; \
|
||||
++pass_count; \
|
||||
} while (0)
|
||||
|
||||
#define FAIL(label, detail) \
|
||||
do { \
|
||||
std::cout << " FAIL: " << (label) << "\n"; \
|
||||
std::cout << " " << (detail) << "\n"; \
|
||||
++fail_count; \
|
||||
} while (0)
|
||||
|
||||
#define ASSERT_TRUE(cond, label) \
|
||||
do { \
|
||||
if (cond) { \
|
||||
PASS(label); \
|
||||
} else { \
|
||||
FAIL(label, "condition was false"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define ASSERT_CONTAINS(haystack, needle, label) \
|
||||
do { \
|
||||
if (std::string(haystack).find(needle) != std::string::npos) { \
|
||||
PASS(label); \
|
||||
} else { \
|
||||
FAIL(label, "'" + std::string(haystack) + "' does not contain '" + \
|
||||
std::string(needle) + "'"); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
#define ASSERT_ELEMENT_EXISTS(session, selector) \
|
||||
do { \
|
||||
try { \
|
||||
(session).css(selector); \
|
||||
PASS("Element " selector " exists"); \
|
||||
} catch (...) { FAIL("Element " selector " exists", "not found"); } \
|
||||
} while (0)
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────
|
||||
|
||||
static std::string base_url;
|
||||
|
||||
void navigate_and_wait_for_models(webdriver::Session &session) {
|
||||
session.navigate(base_url);
|
||||
session.wait_until(
|
||||
"return document.querySelectorAll('#model-select option').length > 0",
|
||||
5000);
|
||||
}
|
||||
|
||||
void test_page_loads(webdriver::Session &session) {
|
||||
std::cout << "=== TC1: Page loads with correct structure\n";
|
||||
|
||||
session.navigate(base_url);
|
||||
|
||||
auto title = session.title();
|
||||
ASSERT_CONTAINS(title, "Translate", "Page title contains 'Translate'");
|
||||
|
||||
// Verify main DOM elements exist
|
||||
ASSERT_ELEMENT_EXISTS(session, "#model-select");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#input-text");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#output-text");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#target-lang");
|
||||
}
|
||||
|
||||
void test_model_dropdown(webdriver::Session &session) {
|
||||
std::cout << "=== TC2: Model dropdown is populated\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
// Note: WebDriver findElements cannot find <option> elements directly
|
||||
// in geckodriver/Firefox, so we use JS to count them.
|
||||
auto option_count = session.execute_script(
|
||||
"return document.querySelectorAll('#model-select option').length");
|
||||
ASSERT_TRUE(option_count != "0" && option_count != "null",
|
||||
"Model dropdown has options (count=" + option_count + ")");
|
||||
|
||||
// Check that at least one option has a selected attribute
|
||||
auto selected_val = session.execute_script(
|
||||
"return document.querySelector('#model-select').value");
|
||||
ASSERT_TRUE(selected_val != "null" && !selected_val.empty(),
|
||||
"A model is selected (value='" + selected_val + "')");
|
||||
}
|
||||
|
||||
void test_translation_sse(webdriver::Session &session) {
|
||||
std::cout << "=== TC3: Translation with SSE streaming\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
// Clear and type input — debounce auto-translate triggers after 300ms
|
||||
auto input = session.css("#input-text");
|
||||
input.clear();
|
||||
input.send_keys("Hello world");
|
||||
|
||||
// Wait for output to appear (debounce 300ms + LLM inference)
|
||||
bool has_output = session.wait_until(
|
||||
"return document.querySelector('#output-text').textContent.length > 0",
|
||||
120000);
|
||||
ASSERT_TRUE(has_output, "Translation output appeared");
|
||||
|
||||
auto output_text = session.execute_script(
|
||||
"return document.querySelector('#output-text').textContent");
|
||||
ASSERT_TRUE(!output_text.empty() && output_text != "null",
|
||||
"Output text is non-empty ('" + output_text.substr(0, 50) +
|
||||
"...')");
|
||||
|
||||
// Wait for busy state to be cleared after completion
|
||||
bool busy_cleared = session.wait_until(
|
||||
"return !document.body.classList.contains('busy')", 120000);
|
||||
ASSERT_TRUE(busy_cleared, "Busy state cleared after translation");
|
||||
}
|
||||
|
||||
void test_busy_state(webdriver::Session &session) {
|
||||
std::cout << "=== TC4: Busy state during translation\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
auto input = session.css("#input-text");
|
||||
input.clear();
|
||||
|
||||
// Clear previous output
|
||||
session.execute_script(
|
||||
"document.querySelector('#output-text').textContent = ''");
|
||||
|
||||
input.send_keys(
|
||||
"I had a great time visiting Tokyo last spring. "
|
||||
"The cherry blossoms were beautiful and the food was amazing.");
|
||||
|
||||
// Check busy state (debounce 300ms then translation starts)
|
||||
bool went_busy = session.wait_until(
|
||||
"return document.body.classList.contains('busy')", 5000);
|
||||
ASSERT_TRUE(went_busy, "Body gets 'busy' class during translation");
|
||||
|
||||
// Wait for completion
|
||||
session.wait_until("return !document.body.classList.contains('busy')",
|
||||
120000);
|
||||
PASS("Busy class removed after completion");
|
||||
}
|
||||
|
||||
void test_empty_input(webdriver::Session &session) {
|
||||
std::cout << "=== TC5: Empty input does nothing\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
// Clear input and output
|
||||
auto input = session.css("#input-text");
|
||||
input.clear();
|
||||
session.execute_script(
|
||||
"document.querySelector('#output-text').textContent = ''");
|
||||
|
||||
// Trigger input event on empty textarea
|
||||
session.execute_script("document.querySelector('#input-text').dispatchEvent("
|
||||
" new Event('input'));");
|
||||
|
||||
// Wait longer than debounce (300ms) — nothing should happen
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(1000));
|
||||
|
||||
auto output_text = session.execute_script(
|
||||
"return document.querySelector('#output-text').textContent");
|
||||
ASSERT_TRUE(output_text.empty() || output_text == "null" || output_text == "",
|
||||
"No output for empty input");
|
||||
}
|
||||
|
||||
void test_target_lang_selector(webdriver::Session &session) {
|
||||
std::cout << "=== TC6: Target language selector\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
// Check available language options (use JS — WebDriver can't find <option>)
|
||||
auto lang_count = session.execute_script(
|
||||
"return document.querySelectorAll('#target-lang option').length");
|
||||
ASSERT_TRUE(lang_count != "0" && lang_count != "null",
|
||||
"Language selector has multiple options (count=" + lang_count +
|
||||
")");
|
||||
|
||||
// Switch to English and translate
|
||||
session.execute_script("document.querySelector('#target-lang').value = 'en';"
|
||||
"document.querySelector('#target-lang').dispatchEvent("
|
||||
" new Event('change'));");
|
||||
|
||||
// Clear output, then type — debounce auto-translate triggers
|
||||
session.execute_script(
|
||||
"document.querySelector('#output-text').textContent = ''");
|
||||
|
||||
auto input = session.css("#input-text");
|
||||
input.clear();
|
||||
input.send_keys("こんにちは");
|
||||
|
||||
bool has_output = session.wait_until(
|
||||
"return document.querySelector('#output-text').textContent.length > 0",
|
||||
120000);
|
||||
ASSERT_TRUE(has_output, "Translation with target_lang=en produced output");
|
||||
}
|
||||
|
||||
void test_model_switch(webdriver::Session &session) {
|
||||
std::cout << "=== TC7: Model switching\n";
|
||||
|
||||
navigate_and_wait_for_models(session);
|
||||
|
||||
auto options = session.css_all("#model-select option");
|
||||
if (options.size() < 2) {
|
||||
PASS("Model switch skipped (only 1 model available)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Get current model
|
||||
auto current = session.execute_script(
|
||||
"return document.querySelector('#model-select').value");
|
||||
|
||||
// Switch to a different model (pick the second option's value)
|
||||
auto other_value = options[1].attribute("value");
|
||||
if (other_value == current && options.size() > 2) {
|
||||
other_value = options[2].attribute("value");
|
||||
}
|
||||
|
||||
session.execute_script(
|
||||
"document.querySelector('#model-select').value = '" + other_value +
|
||||
"';"
|
||||
"document.querySelector('#model-select').dispatchEvent("
|
||||
" new Event('change'));");
|
||||
|
||||
// Wait for model switch to complete (SSE: downloading → loading → ready)
|
||||
bool ready = session.wait_until(
|
||||
"return !document.body.classList.contains('busy')", 120000);
|
||||
ASSERT_TRUE(ready, "Model switch completed");
|
||||
|
||||
auto new_value = session.execute_script(
|
||||
"return document.querySelector('#model-select').value");
|
||||
ASSERT_TRUE(new_value == other_value,
|
||||
"Model changed to '" + other_value + "'");
|
||||
}
|
||||
|
||||
void test_download_dialog_structure(webdriver::Session &session) {
|
||||
std::cout << "=== TC8: Download dialog DOM structure\n";
|
||||
|
||||
session.navigate(base_url);
|
||||
|
||||
ASSERT_ELEMENT_EXISTS(session, "#download-dialog");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#download-progress");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#download-status");
|
||||
ASSERT_ELEMENT_EXISTS(session, "#download-cancel");
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────
|
||||
|
||||
int main(int argc, char *argv[]) {
|
||||
if (argc < 2) {
|
||||
std::cerr << "Usage: test_webui <server-port>\n";
|
||||
return 1;
|
||||
}
|
||||
|
||||
int port = std::atoi(argv[1]);
|
||||
base_url = "http://127.0.0.1:" + std::to_string(port);
|
||||
|
||||
std::cout << "=== Ch5 Web UI Browser Tests\n";
|
||||
std::cout << "=== Server: " << base_url << "\n\n";
|
||||
|
||||
try {
|
||||
webdriver::Session session;
|
||||
|
||||
test_page_loads(session);
|
||||
test_model_dropdown(session);
|
||||
test_translation_sse(session);
|
||||
test_busy_state(session);
|
||||
test_empty_input(session);
|
||||
test_target_lang_selector(session);
|
||||
test_model_switch(session);
|
||||
test_download_dialog_structure(session);
|
||||
|
||||
} catch (const webdriver::Error &e) {
|
||||
std::cerr << "WebDriver error: " << e.what() << "\n";
|
||||
++fail_count;
|
||||
} catch (const std::exception &e) {
|
||||
std::cerr << "Error: " << e.what() << "\n";
|
||||
++fail_count;
|
||||
}
|
||||
|
||||
std::cout << "\n=== Results: " << pass_count << " passed, " << fail_count
|
||||
<< " failed\n";
|
||||
|
||||
return fail_count > 0 ? 1 : 0;
|
||||
}
|
||||
278
docs-util/llm-app/webdriver.h
Normal file
278
docs-util/llm-app/webdriver.h
Normal file
@@ -0,0 +1,278 @@
|
||||
// webdriver.h — Thin W3C WebDriver client using cpp-httplib + nlohmann/json.
|
||||
// SPDX-License-Identifier: MIT
|
||||
//
|
||||
// Usage:
|
||||
// webdriver::Session session; // starts headless Firefox via geckodriver
|
||||
// session.navigate("http://localhost:8080");
|
||||
// auto el = session.css("h1");
|
||||
// assert(el.text() == "Hello!");
|
||||
// // session destructor closes the browser
|
||||
#pragma once
|
||||
|
||||
#include "httplib.h"
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <stdexcept>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <vector>
|
||||
|
||||
namespace webdriver {
|
||||
|
||||
using json = nlohmann::json;
|
||||
|
||||
// ─── Errors ──────────────────────────────────────────────────
|
||||
|
||||
class Error : public std::runtime_error {
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
// ─── Forward declarations ────────────────────────────────────
|
||||
|
||||
class Session;
|
||||
|
||||
// ─── Element ─────────────────────────────────────────────────
|
||||
|
||||
class Element {
|
||||
friend class Session;
|
||||
|
||||
httplib::Client *cli_;
|
||||
std::string session_id_;
|
||||
std::string element_id_;
|
||||
|
||||
public:
|
||||
Element(httplib::Client *cli, const std::string &session_id,
|
||||
const std::string &element_id)
|
||||
: cli_(cli), session_id_(session_id), element_id_(element_id) {}
|
||||
|
||||
std::string url(const std::string &suffix = "") const {
|
||||
return "/session/" + session_id_ + "/element/" + element_id_ + suffix;
|
||||
}
|
||||
|
||||
public:
|
||||
std::string text() const {
|
||||
auto res = cli_->Get(url("/text"));
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to get element text");
|
||||
}
|
||||
return json::parse(res->body)["value"].get<std::string>();
|
||||
}
|
||||
|
||||
std::string attribute(const std::string &name) const {
|
||||
auto res = cli_->Get(url("/attribute/" + name));
|
||||
if (!res || res->status != 200) { return ""; }
|
||||
auto val = json::parse(res->body)["value"];
|
||||
return val.is_null() ? "" : val.get<std::string>();
|
||||
}
|
||||
|
||||
std::string property(const std::string &name) const {
|
||||
auto res = cli_->Get(url("/property/" + name));
|
||||
if (!res || res->status != 200) { return ""; }
|
||||
auto val = json::parse(res->body)["value"];
|
||||
return val.is_null() ? "" : val.get<std::string>();
|
||||
}
|
||||
|
||||
void click() const {
|
||||
auto res = cli_->Post(url("/click"), "{}", "application/json");
|
||||
if (!res || res->status != 200) { throw Error("Failed to click element"); }
|
||||
}
|
||||
|
||||
void send_keys(const std::string &keys) const {
|
||||
json body = {{"text", keys}};
|
||||
auto res = cli_->Post(url("/value"), body.dump(), "application/json");
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to send keys to element");
|
||||
}
|
||||
}
|
||||
|
||||
void clear() const {
|
||||
auto res = cli_->Post(url("/clear"), "{}", "application/json");
|
||||
if (!res || res->status != 200) { throw Error("Failed to clear element"); }
|
||||
}
|
||||
|
||||
std::string tag_name() const {
|
||||
auto res = cli_->Get(url("/name"));
|
||||
if (!res || res->status != 200) { throw Error("Failed to get tag name"); }
|
||||
return json::parse(res->body)["value"].get<std::string>();
|
||||
}
|
||||
|
||||
bool is_displayed() const {
|
||||
auto res = cli_->Get(url("/displayed"));
|
||||
if (!res || res->status != 200) { return false; }
|
||||
return json::parse(res->body)["value"].get<bool>();
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Session ─────────────────────────────────────────────────
|
||||
|
||||
class Session {
|
||||
httplib::Client cli_;
|
||||
std::string session_id_;
|
||||
|
||||
// W3C WebDriver uses this key for element references
|
||||
static constexpr const char *ELEMENT_KEY =
|
||||
"element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
std::string extract_element_id(const json &value) const {
|
||||
if (value.contains(ELEMENT_KEY)) {
|
||||
return value[ELEMENT_KEY].get<std::string>();
|
||||
}
|
||||
// Fallback: try "ELEMENT" (older protocol)
|
||||
if (value.contains("ELEMENT")) {
|
||||
return value["ELEMENT"].get<std::string>();
|
||||
}
|
||||
throw Error("No element identifier in response: " + value.dump());
|
||||
}
|
||||
|
||||
std::string url(const std::string &suffix) const {
|
||||
return "/session/" + session_id_ + suffix;
|
||||
}
|
||||
|
||||
public:
|
||||
explicit Session(const std::string &host = "127.0.0.1", int port = 4444)
|
||||
: cli_(host, port) {
|
||||
cli_.set_read_timeout(std::chrono::seconds(30));
|
||||
cli_.set_connection_timeout(std::chrono::seconds(5));
|
||||
|
||||
json caps = {
|
||||
{"capabilities",
|
||||
{{"alwaysMatch",
|
||||
{{"moz:firefoxOptions", {{"args", json::array({"-headless"})}}}}}}}};
|
||||
|
||||
auto res = cli_.Post("/session", caps.dump(), "application/json");
|
||||
if (!res) { throw Error("Cannot connect to geckodriver"); }
|
||||
if (res->status != 200) {
|
||||
throw Error("Failed to create session: " + res->body);
|
||||
}
|
||||
|
||||
auto body = json::parse(res->body);
|
||||
session_id_ = body["value"]["sessionId"].get<std::string>();
|
||||
}
|
||||
|
||||
~Session() {
|
||||
try {
|
||||
cli_.Delete(url(""));
|
||||
} catch (...) {}
|
||||
}
|
||||
|
||||
// Non-copyable, non-movable (owns a session)
|
||||
Session(const Session &) = delete;
|
||||
Session &operator=(const Session &) = delete;
|
||||
|
||||
// ─── Navigation ──────────────────────────────────────────
|
||||
|
||||
void navigate(const std::string &nav_url) {
|
||||
json body = {{"url", nav_url}};
|
||||
auto res = cli_.Post(url("/url"), body.dump(), "application/json");
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to navigate to: " + nav_url);
|
||||
}
|
||||
}
|
||||
|
||||
std::string title() {
|
||||
auto res = cli_.Get(url("/title"));
|
||||
if (!res || res->status != 200) { throw Error("Failed to get title"); }
|
||||
return json::parse(res->body)["value"].get<std::string>();
|
||||
}
|
||||
|
||||
std::string current_url() {
|
||||
auto res = cli_.Get(url("/url"));
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to get current URL");
|
||||
}
|
||||
return json::parse(res->body)["value"].get<std::string>();
|
||||
}
|
||||
|
||||
// ─── Find elements ──────────────────────────────────────
|
||||
|
||||
Element find(const std::string &using_, const std::string &value) {
|
||||
json body = {{"using", using_}, {"value", value}};
|
||||
auto res = cli_.Post(url("/element"), body.dump(), "application/json");
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Element not found: " + using_ + "=" + value);
|
||||
}
|
||||
auto eid = extract_element_id(json::parse(res->body)["value"]);
|
||||
return Element(&cli_, session_id_, eid);
|
||||
}
|
||||
|
||||
std::vector<Element> find_all(const std::string &using_,
|
||||
const std::string &value) {
|
||||
json body = {{"using", using_}, {"value", value}};
|
||||
auto res = cli_.Post(url("/elements"), body.dump(), "application/json");
|
||||
if (!res || res->status != 200) { return {}; }
|
||||
|
||||
std::vector<Element> elements;
|
||||
for (auto &v : json::parse(res->body)["value"]) {
|
||||
elements.emplace_back(&cli_, session_id_, extract_element_id(v));
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
// Convenience: find by CSS selector
|
||||
Element css(const std::string &selector) {
|
||||
return find("css selector", selector);
|
||||
}
|
||||
|
||||
std::vector<Element> css_all(const std::string &selector) {
|
||||
return find_all("css selector", selector);
|
||||
}
|
||||
|
||||
// ─── Wait ────────────────────────────────────────────────
|
||||
|
||||
// Poll for an element until it appears or timeout
|
||||
Element wait_for(const std::string &selector, int timeout_ms = 5000) {
|
||||
auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(timeout_ms);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
try {
|
||||
return css(selector);
|
||||
} catch (...) {
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
}
|
||||
}
|
||||
throw Error("Timeout waiting for element: " + selector);
|
||||
}
|
||||
|
||||
// Wait until a JS expression returns truthy
|
||||
bool wait_until(const std::string &script, int timeout_ms = 5000) {
|
||||
auto deadline = std::chrono::steady_clock::now() +
|
||||
std::chrono::milliseconds(timeout_ms);
|
||||
while (std::chrono::steady_clock::now() < deadline) {
|
||||
auto result = execute_script(script);
|
||||
if (result != "null" && result != "false" && result != "" &&
|
||||
result != "0" && result != "undefined") {
|
||||
return true;
|
||||
}
|
||||
std::this_thread::sleep_for(std::chrono::milliseconds(200));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ─── Execute script ─────────────────────────────────────
|
||||
|
||||
std::string execute_script(const std::string &script,
|
||||
const json &args = json::array()) {
|
||||
json body = {{"script", script}, {"args", args}};
|
||||
auto res = cli_.Post(url("/execute/sync"), body.dump(), "application/json");
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to execute script: " + script);
|
||||
}
|
||||
auto val = json::parse(res->body)["value"];
|
||||
if (val.is_null()) { return "null"; }
|
||||
if (val.is_string()) { return val.get<std::string>(); }
|
||||
return val.dump();
|
||||
}
|
||||
|
||||
// ─── Page source ────────────────────────────────────────
|
||||
|
||||
std::string page_source() {
|
||||
auto res = cli_.Get(url("/source"));
|
||||
if (!res || res->status != 200) {
|
||||
throw Error("Failed to get page source");
|
||||
}
|
||||
return json::parse(res->body)["value"].get<std::string>();
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace webdriver
|
||||
Reference in New Issue
Block a user