Files
BarangaySystem/scripts/perf-test.sh
2026-06-06 18:43:00 +08:00

557 lines
19 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
# =============================================================================
# BukidBounty Performance & Load Test Script
#
# Usage:
# ./scripts/perf-test.sh [options] [test...]
#
# Options:
# -u, --url URL Base URL (default: http://localhost:9522)
# -t, --token TOKEN X-Perf-Token value (default: $PERF_API_TOKEN env)
# -s, --store HASH Store hashkey for POS simulation (required for pos/*)
# -c, --concurrency N Max concurrent workers for ramp test (default: 50)
# -o, --output FILE Write JSON summary to FILE
# --no-color Disable colored output
#
# Tests (pass one or more; default: all):
# ping Verify connectivity and token
# seed Create 50 users + 20 stores + 100 products, report timing
# pos POS simulation: 1 → 5 → 20 items × 20 cycles
# concurrent Hit /api/perf/pos/simulate with N parallel workers
# ramp Ramp concurrency from 1 → MAX workers, report throughput
# web Curl-based throughput on public endpoints (no token needed)
#
# Environment:
# PERF_API_TOKEN Token set in .env on the server (required)
# PERF_BASE_URL Overrides --url
# PERF_STORE_HASH Overrides --store
#
# Examples:
# # Quick health check (local dev):
# PERF_API_TOKEN=secret ./scripts/perf-test.sh ping
#
# # Full suite against prod:
# ./scripts/perf-test.sh -u https://yourapp.com -t SECRET -s STORE_HASH
#
# # Concurrent POS with 30 workers:
# ./scripts/perf-test.sh -u https://yourapp.com -t SECRET -s STORE_HASH \
# -c 30 concurrent
#
# # Ramp test only, save JSON report:
# ./scripts/perf-test.sh ... ramp -o results.json
# =============================================================================
set -euo pipefail
# ---------------------------------------------------------------------------
# Defaults / env
# ---------------------------------------------------------------------------
BASE_URL="${PERF_BASE_URL:-http://localhost:9522}"
TOKEN="${PERF_API_TOKEN:-}"
STORE_HASH="${PERF_STORE_HASH:-}"
MAX_CONCURRENCY=50
OUTPUT_FILE=""
USE_COLOR=true
RUN_TESTS=()
# ---------------------------------------------------------------------------
# Arg parsing
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-u|--url) BASE_URL="$2"; shift 2 ;;
-t|--token) TOKEN="$2"; shift 2 ;;
-s|--store) STORE_HASH="$2"; shift 2 ;;
-c|--concurrency) MAX_CONCURRENCY="$2"; shift 2 ;;
-o|--output) OUTPUT_FILE="$2"; shift 2 ;;
--no-color) USE_COLOR=false; shift ;;
ping|seed|pos|concurrent|ramp|web)
RUN_TESTS+=("$1"); shift ;;
*) echo "Unknown option: $1" >&2; exit 1 ;;
esac
done
[[ ${#RUN_TESTS[@]} -eq 0 ]] && RUN_TESTS=(ping seed pos concurrent ramp web)
BASE_URL="${BASE_URL%/}" # strip trailing slash
# ---------------------------------------------------------------------------
# Colors
# ---------------------------------------------------------------------------
if $USE_COLOR && [[ -t 1 ]]; then
C_RESET="\033[0m"; C_BOLD="\033[1m"
C_GREEN="\033[32m"; C_YELLOW="\033[33m"; C_RED="\033[31m"
C_CYAN="\033[36m"; C_BLUE="\033[34m"; C_DIM="\033[2m"
else
C_RESET=""; C_BOLD=""; C_GREEN=""; C_YELLOW=""; C_RED=""
C_CYAN=""; C_BLUE=""; C_DIM=""
fi
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
NOW() { date +"%Y-%m-%d %H:%M:%S"; }
log_section() { echo -e "\n${C_BOLD}${C_BLUE}══ $1 ══${C_RESET}"; }
log_ok() { echo -e " ${C_GREEN}${C_RESET} $*"; }
log_warn() { echo -e " ${C_YELLOW}${C_RESET} $*"; }
log_err() { echo -e " ${C_RED}${C_RESET} $*"; }
log_dim() { echo -e " ${C_DIM}$*${C_RESET}"; }
# Check required tools
need() { command -v "$1" &>/dev/null || { log_err "Required tool not found: $1 — install it first"; exit 1; }; }
need curl
need jq
need bc
need awk
# curl wrapper: returns HTTP body; exits 1 on HTTP error or curl failure
# Usage: perf_get PATH
perf_get() {
local path="$1"
curl -sf --max-time 30 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Accept: application/json" \
"${BASE_URL}${path}"
}
# POST JSON body
perf_post() {
local path="$1"
local body="$2"
curl -sf --max-time 60 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$body" \
"${BASE_URL}${path}"
}
# POST silently into a tmp file; echo that file path
perf_post_async() {
local path="$1" body="$2" out="$3"
curl -s --max-time 60 \
-H "X-Perf-Token: ${TOKEN}" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d "$body" \
"${BASE_URL}${path}" -o "$out" 2>/dev/null
}
# Extract a numeric field from JSON
jnum() { echo "$1" | jq -r ".$2 // 0"; }
# Pretty-print a ms value
ms_label() {
local v
v=$(printf "%.1f" "${1:-0}")
if (( $(echo "$v < 100" | bc -l) )); then
echo -e "${C_GREEN}${v}ms${C_RESET}"
elif (( $(echo "$v < 500" | bc -l) )); then
echo -e "${C_YELLOW}${v}ms${C_RESET}"
else
echo -e "${C_RED}${v}ms${C_RESET}"
fi
}
# ---------------------------------------------------------------------------
# JSON results accumulator
# ---------------------------------------------------------------------------
RESULTS='{"run_at":"","tests":{}}'
result_set() {
# result_set <test> <key> <value(json)>
RESULTS=$(echo "$RESULTS" | jq --arg t "$1" --arg k "$2" --argjson v "$3" \
'.tests[$t][$k] = $v')
}
result_section() {
RESULTS=$(echo "$RESULTS" | jq --arg t "$1" '.tests[$t] //= {}')
}
# ---------------------------------------------------------------------------
# Guard: token must be set for API tests
# ---------------------------------------------------------------------------
require_token() {
if [[ -z "$TOKEN" ]]; then
log_err "PERF_API_TOKEN / --token is required for this test"
exit 1
fi
}
require_store() {
if [[ -z "$STORE_HASH" ]]; then
log_err "--store / PERF_STORE_HASH is required for this test"
exit 1
fi
}
# =============================================================================
# TEST: ping
# =============================================================================
test_ping() {
log_section "PING — Connectivity & token check"
require_token
result_section "ping"
local t0 body
t0=$(date +%s%N)
if ! body=$(perf_get "/api/perf/ping" 2>&1); then
log_err "Ping failed: $body"
result_set ping ok false
return 1
fi
local elapsed_ms
elapsed_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
local ts php
ts=$(echo "$body" | jq -r '.ts // "?"')
php=$(echo "$body" | jq -r '.php // "?"')
log_ok "Server responded in $(ms_label $elapsed_ms)"
log_ok "Server time: ${ts} PHP: ${php}"
log_dim "Target: ${BASE_URL}"
result_set ping ok true
result_set ping latency_ms "$elapsed_ms"
result_set ping server_time "\"$ts\""
}
# =============================================================================
# TEST: seed
# =============================================================================
test_seed() {
log_section "SEED — Create synthetic users / stores / products"
require_token
result_section "seed"
# 1. Seed users
local r
echo -e " Seeding 50 users..."
r=$(perf_post "/api/perf/seed/users" '{"count":50,"prefix":"loadtest"}')
local u_ms u_avg
u_ms=$(jnum "$r" total_ms)
u_avg=$(jnum "$r" avg_ms)
log_ok "Users — 50 created total: $(ms_label $u_ms) avg/user: $(ms_label $u_avg)"
result_set seed users_total_ms "$u_ms"
result_set seed users_avg_ms "$u_avg"
# 2. Seed stores
echo -e " Seeding 20 stores..."
r=$(perf_post "/api/perf/seed/stores" '{"count":20,"prefix":"LoadTestStore"}')
local s_ms s_avg
s_ms=$(jnum "$r" total_ms)
s_avg=$(jnum "$r" avg_ms)
log_ok "Stores — 20 created total: $(ms_label $s_ms) avg/store: $(ms_label $s_avg)"
result_set seed stores_total_ms "$s_ms"
result_set seed stores_avg_ms "$s_avg"
# 3. Seed products (global, no store attach)
echo -e " Seeding 100 products..."
r=$(perf_post "/api/perf/seed/products" '{"count":100,"prefix":"LoadTestPrd","attach_to_store":false}')
local p_ms p_avg
p_ms=$(jnum "$r" total_ms)
p_avg=$(jnum "$r" avg_ms)
log_ok "Products— 100 created total: $(ms_label $p_ms) avg/prod: $(ms_label $p_avg)"
result_set seed products_total_ms "$p_ms"
result_set seed products_avg_ms "$p_avg"
}
# =============================================================================
# TEST: pos — sequential POS simulation at various item counts
# =============================================================================
test_pos() {
log_section "POS — Sequential simulation (1 / 5 / 20 items × 20 cycles)"
require_token
require_store
result_section "pos"
local scenario items cycles
for scenario in "1items:1:20" "5items:5:20" "20items:20:20"; do
label="${scenario%%:*}"
rest="${scenario#*:}"
items="${rest%%:*}"
cycles="${rest#*:}"
echo -e " Running ${cycles} cycles × ${items} items..."
local r avg_ms min_ms max_ms
r=$(perf_post "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":${items},\"cycles\":${cycles},\"complete\":true}")
avg_ms=$(jnum "$r" avg_cycle_ms)
min_ms=$(jnum "$r" min_cycle_ms)
max_ms=$(jnum "$r" max_cycle_ms)
log_ok "${label} avg: $(ms_label $avg_ms) min: $(ms_label $min_ms) max: $(ms_label $max_ms)"
result_set pos "${label}_avg_ms" "$avg_ms"
result_set pos "${label}_min_ms" "$min_ms"
result_set pos "${label}_max_ms" "$max_ms"
done
}
# =============================================================================
# TEST: concurrent — N parallel POS simulations at once
# =============================================================================
test_concurrent() {
log_section "CONCURRENT — Parallel POS workers (5 items / session)"
require_token
require_store
result_section "concurrent"
local workers_list=(1 5 10 25 $MAX_CONCURRENCY)
# deduplicate and sort
workers_list=($(printf '%s\n' "${workers_list[@]}" | sort -nu))
local TMPDIR_PERF
TMPDIR_PERF=$(mktemp -d)
trap "rm -rf '$TMPDIR_PERF'" EXIT
for workers in "${workers_list[@]}"; do
[[ $workers -gt $MAX_CONCURRENCY ]] && continue
local pids=() out_files=()
local t0
t0=$(date +%s%N)
for ((w=1; w<=workers; w++)); do
local out="${TMPDIR_PERF}/w${workers}_${w}.json"
out_files+=("$out")
perf_post_async "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":5,\"cycles\":3,\"complete\":true}" \
"$out" &
pids+=($!)
done
# Wait for all workers
local failed=0
for pid in "${pids[@]}"; do
wait "$pid" 2>/dev/null || (( failed++ )) || true
done
local wall_ms errors=0 total_avg=0 count=0
wall_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
for f in "${out_files[@]}"; do
if [[ ! -f "$f" ]]; then (( errors++ )); continue; fi
local ok avg
ok=$(jq -r '.success // false' "$f" 2>/dev/null)
if [[ "$ok" != "true" ]]; then (( errors++ )); continue; fi
avg=$(jq -r '.avg_cycle_ms // 0' "$f" 2>/dev/null)
total_avg=$(echo "$total_avg + $avg" | bc)
(( count++ ))
done
local mean_avg=0
[[ $count -gt 0 ]] && mean_avg=$(echo "scale=1; $total_avg / $count" | bc)
local status_icon="${C_GREEN}${C_RESET}"
[[ $errors -gt 0 ]] && status_icon="${C_RED}${C_RESET}"
printf " ${status_icon} %3d workers wall: $(ms_label $wall_ms) mean cycle: $(ms_label $mean_avg) errors: %d\n" \
"$workers" "$errors"
result_set concurrent "w${workers}_wall_ms" "$wall_ms"
result_set concurrent "w${workers}_mean_cycle_ms" "$mean_avg"
result_set concurrent "w${workers}_errors" "$errors"
done
rm -rf "$TMPDIR_PERF"
trap - EXIT
}
# =============================================================================
# TEST: ramp — gradually increase concurrency to find the saturation point
# =============================================================================
test_ramp() {
log_section "RAMP — Concurrency ramp 1 → ${MAX_CONCURRENCY} workers"
require_token
require_store
result_section "ramp"
# Steps: 1, 5, 10, 20, 30, MAX (if not already listed)
local steps=(1 5 10 20 30)
steps+=($MAX_CONCURRENCY)
steps=($(printf '%s\n' "${steps[@]}" | sort -nu | awk -v m=$MAX_CONCURRENCY '$1<=m'))
local TMPDIR_RAMP
TMPDIR_RAMP=$(mktemp -d)
trap "rm -rf '$TMPDIR_RAMP'" EXIT
echo -e " ${C_DIM}workers | wall_ms | mean_cycle_ms | throughput(cyc/s) | errors${C_RESET}"
echo -e " ${C_DIM}--------|---------|---------------|-----------------|-------${C_RESET}"
local prev_throughput=0
local saturation_workers=""
for workers in "${steps[@]}"; do
local pids=() out_files=()
local t0
t0=$(date +%s%N)
for ((w=1; w<=workers; w++)); do
local out="${TMPDIR_RAMP}/ramp_${workers}_${w}.json"
out_files+=("$out")
perf_post_async "/api/perf/pos/simulate" \
"{\"store_hash\":\"${STORE_HASH}\",\"items\":5,\"cycles\":5,\"complete\":true}" \
"$out" &
pids+=($!)
done
for pid in "${pids[@]}"; do wait "$pid" 2>/dev/null || true; done
local wall_ms errors=0 total_cycles=0 total_avg=0 count=0
wall_ms=$(( ( $(date +%s%N) - t0 ) / 1000000 ))
for f in "${out_files[@]}"; do
[[ ! -f "$f" ]] && (( errors++ )) && continue
local ok
ok=$(jq -r '.success // false' "$f" 2>/dev/null)
[[ "$ok" != "true" ]] && (( errors++ )) && continue
local cyc avg
cyc=$(jq -r '.cycles // 0' "$f" 2>/dev/null)
avg=$(jq -r '.avg_cycle_ms // 0' "$f" 2>/dev/null)
total_cycles=$(( total_cycles + cyc ))
total_avg=$(echo "$total_avg + $avg" | bc)
(( count++ ))
done
local mean_avg=0 throughput=0
[[ $count -gt 0 ]] && mean_avg=$(echo "scale=1; $total_avg / $count" | bc)
[[ $wall_ms -gt 0 ]] && throughput=$(echo "scale=1; $total_cycles * 1000 / $wall_ms" | bc)
# Saturation detection: throughput stopped growing by >5%
if [[ -n "$prev_throughput" ]] && (( $(echo "$prev_throughput > 0" | bc -l) )); then
local growth
growth=$(echo "scale=2; ($throughput - $prev_throughput) / $prev_throughput * 100" | bc 2>/dev/null || echo 0)
if (( $(echo "$growth < 5 && $workers > 1" | bc -l) )) && [[ -z "$saturation_workers" ]]; then
saturation_workers=$workers
fi
fi
prev_throughput=$throughput
local err_col="${C_GREEN}${errors}${C_RESET}"
[[ $errors -gt 0 ]] && err_col="${C_RED}${errors}${C_RESET}"
printf " %7d | %7d | %13.1f | %17.1f | ${err_col}\n" \
"$workers" "$wall_ms" "$mean_avg" "$throughput"
result_set ramp "w${workers}" "{\"wall_ms\":$wall_ms,\"mean_cycle_ms\":$mean_avg,\"throughput\":$throughput,\"errors\":$errors}"
done
if [[ -n "$saturation_workers" ]]; then
log_warn "Throughput plateaued around ${saturation_workers} workers — saturation point"
result_set ramp saturation_workers "$saturation_workers"
else
log_ok "No saturation detected at ${MAX_CONCURRENCY} workers — system still scaling"
fi
rm -rf "$TMPDIR_RAMP"
trap - EXIT
}
# =============================================================================
# TEST: web — curl burst on public/session endpoints (no token needed)
# =============================================================================
test_web() {
log_section "WEB — Public endpoint throughput (curl burst, no token)"
result_section "web"
local endpoints=(
"GET|/|Public home"
"GET|/login|Login page"
"GET|/api/public/landing-page|Public landing page API"
)
local TMPDIR_WEB
TMPDIR_WEB=$(mktemp -d)
trap "rm -rf '$TMPDIR_WEB'" EXIT
for entry in "${endpoints[@]}"; do
local method path label
method="${entry%%|*}"; rest="${entry#*|}"; path="${rest%%|*}"; label="${rest#*|}"
# Burst: 20 sequential requests, measure p50/p95/p99
local times=()
local errors=0
for i in $(seq 1 20); do
local t0 t1 ms
t0=$(date +%s%N)
local http_code
http_code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 10 \
-X "$method" "${BASE_URL}${path}" 2>/dev/null || echo "000")
t1=$(date +%s%N)
ms=$(( ( t1 - t0 ) / 1000000 ))
if [[ "$http_code" == "000" || "$http_code" == "5"* ]]; then
(( errors++ ))
else
times+=($ms)
fi
done
if [[ ${#times[@]} -eq 0 ]]; then
log_err "${label} — all 20 requests failed"
result_set web "$path" '{"errors":20}'
continue
fi
# Sort times
local sorted
sorted=($(printf '%s\n' "${times[@]}" | sort -n))
local n=${#sorted[@]}
local p50_idx p95_idx p99_idx
p50_idx=$(( (n * 50) / 100 ))
p95_idx=$(( (n * 95) / 100 ))
p99_idx=$(( (n * 99) / 100 ))
[[ $p50_idx -ge $n ]] && p50_idx=$(( n - 1 ))
[[ $p95_idx -ge $n ]] && p95_idx=$(( n - 1 ))
[[ $p99_idx -ge $n ]] && p99_idx=$(( n - 1 ))
local p50=${sorted[$p50_idx]}
local p95=${sorted[$p95_idx]}
local p99=${sorted[$p99_idx]}
local avg
avg=$(printf '%s\n' "${times[@]}" | awk '{sum+=$1} END {printf "%.0f", sum/NR}')
log_ok "${label}"
log_dim " avg: $(ms_label $avg) p50: $(ms_label $p50) p95: $(ms_label $p95) p99: $(ms_label $p99) errors: $errors / 20"
result_set web "$path" "{\"avg_ms\":$avg,\"p50_ms\":$p50,\"p95_ms\":$p95,\"p99_ms\":$p99,\"errors\":$errors}"
done
rm -rf "$TMPDIR_WEB"
trap - EXIT
}
# =============================================================================
# MAIN
# =============================================================================
echo -e "\n${C_BOLD}${C_CYAN}BukidBounty Performance Test${C_RESET} $(NOW)"
echo -e " Target : ${C_BOLD}${BASE_URL}${C_RESET}"
echo -e " Tests : ${RUN_TESTS[*]}"
[[ -n "$STORE_HASH" ]] && echo -e " Store : ${STORE_HASH}"
RESULTS=$(echo "$RESULTS" | jq --arg d "$(NOW)" '.run_at = $d')
for t in "${RUN_TESTS[@]}"; do
case "$t" in
ping) test_ping || true ;;
seed) test_seed || true ;;
pos) test_pos || true ;;
concurrent) test_concurrent || true ;;
ramp) test_ramp || true ;;
web) test_web || true ;;
*) log_warn "Unknown test: $t (skipped)" ;;
esac
done
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
log_section "SUMMARY"
RESULTS=$(echo "$RESULTS" | jq --arg url "$BASE_URL" '.target = $url')
if [[ -n "$OUTPUT_FILE" ]]; then
echo "$RESULTS" | jq . > "$OUTPUT_FILE"
log_ok "Results written to: ${OUTPUT_FILE}"
fi
echo -e "\n${C_DIM}Tip: run with -o results.json to capture structured results${C_RESET}"
echo -e "${C_DIM}Tip: set PERF_API_TOKEN in .env on the server to enable the perf API${C_RESET}\n"