#!/bin/bash # FolderWeb Performance Testing Tool # All-in-one script for profiling, test data generation, and analysis set -e CONTAINER="${CONTAINER:-folderweb-default}" PORT="${PORT:-8080}" ANALYZER="/tmp/analyze_profile.php" # Colors RED='\033[0;31m' GREEN='\033[0;32m' YELLOW='\033[1;33m' BLUE='\033[0;34m' NC='\033[0m' show_help() { cat << EOF FolderWeb Performance Testing Tool SETUP (First Time): cd devel && podman-compose build && podman-compose up -d PROFILING COMMANDS: profile Profile a URL (add ?XDEBUG_PROFILE=1 and analyze) analyze [file] Analyze a cachegrind file (latest if not specified) list List all available cachegrind files clean Remove all cachegrind profile files TEST DATA COMMANDS: generate Generate synthetic test data in /tmp/test-content Sizes: small (~100 posts), medium (~500), large (~1500), huge (~5000+) generate custom N M D Custom: N categories, M posts/cat, D depth testdata-stats Show test data statistics testdata-clean Remove test data from container USING TEST DATA: Test data is generated at /tmp/test-content inside the container. Option 1 - Symlink (quick): podman exec folderweb-default ln -s /tmp/test-content /var/www/html Option 2 - Edit router.php (temporary): Change \$ctx->contentDir to '/tmp/test-content' EXAMPLES: $0 profile / # Profile homepage $0 generate medium # Generate ~500 posts $0 testdata-stats # View stats $0 profile /blog-321/ # Profile test data # Stress test with huge dataset $0 generate huge && $0 profile / # Export for KCachegrind podman cp folderweb-default:/tmp/cachegrind.out.123 ./profile.out kcachegrind ./profile.out ENVIRONMENT VARIABLES: CONTAINER=name Target container (default: folderweb-default) PORT=port Target port (default: 8080) RESULTS: Focus on functions taking >5% of execution time. Time (ms) = total time spent | Memory (KB) = total allocated Calls = invocation count | Percentage = % of total time EOF } ensure_analyzer() { if ! podman exec "$CONTAINER" test -f "$ANALYZER" 2>/dev/null; then echo -e "${YELLOW}Installing profile analyzer...${NC}" >&2 podman exec "$CONTAINER" bash -c "cat > $ANALYZER" << 'EOFANALYZER' \n"; exit(1); } $file = $argv[1]; if (!file_exists($file)) { echo "Error: File not found: $file\n"; exit(1); } $lines = file($file); $functions = []; $currentFile = ''; $currentFunction = ''; foreach ($lines as $line) { $line = trim($line); if (str_starts_with($line, 'fl=')) { preg_match('/fl=\(?\d*\)?\s*(.+)/', $line, $matches); $currentFile = $matches[1] ?? ''; } elseif (str_starts_with($line, 'fn=')) { preg_match('/fn=\(?\d*\)?\s*(.+)/', $line, $matches); $currentFunction = $matches[1] ?? ''; } elseif (preg_match('/^(\d+)\s+(\d+)\s+(\d+)/', $line, $matches)) { $time = (int)$matches[2]; $memory = (int)$matches[3]; if ($currentFunction && $time > 0) { if (!isset($functions[$currentFunction])) { $functions[$currentFunction] = ['name' => $currentFunction, 'file' => $currentFile, 'time' => 0, 'memory' => 0, 'calls' => 0]; } $functions[$currentFunction]['time'] += $time; $functions[$currentFunction]['memory'] += $memory; $functions[$currentFunction]['calls']++; } } } usort($functions, fn($a, $b) => $b['time'] <=> $a['time']); echo "\n=== TOP 20 SLOWEST FUNCTIONS ===\n\n"; echo str_pad("Time (ms)", 12) . str_pad("Memory (KB)", 14) . str_pad("Calls", 8) . "Function\n"; echo str_repeat("-", 100) . "\n"; $total_time = array_sum(array_column($functions, 'time')); foreach (array_slice($functions, 0, 20) as $fn) { $time_ms = round($fn['time'] / 100000, 2); $memory_kb = round($fn['memory'] / 1024, 2); $percentage = round(($fn['time'] / $total_time) * 100, 1); $funcName = strlen($fn['name']) > 60 ? substr($fn['name'], 0, 57) . '...' : $fn['name']; echo str_pad($time_ms, 12) . str_pad($memory_kb, 14) . str_pad($fn['calls'], 8) . $funcName . " ({$percentage}%)\n"; if ($fn['file'] && $fn['file'] !== 'php:internal') { echo str_repeat(" ", 34) . "└─ {$fn['file']}\n"; } } echo "\nTotal execution time: " . round($total_time / 100000, 2) . " ms\n\n"; EOFANALYZER fi } cmd_profile() { local url="${1:-/}" echo -e "${GREEN}Profiling: $url (container: $CONTAINER, port: $PORT)${NC}" local response=$(curl -s -I "http://localhost:${PORT}${url}?XDEBUG_PROFILE=1") local profile_file=$(echo "$response" | grep -i "X-Xdebug-Profile-Filename" | cut -d' ' -f2 | tr -d '\r') if [ -z "$profile_file" ]; then echo -e "${RED}Error: No profile generated. Is Xdebug installed?${NC}" echo -e "${YELLOW}Tip: Rebuild container with: cd devel && podman-compose build${NC}" exit 1 fi echo -e "${GREEN}Profile generated: $profile_file${NC}" ensure_analyzer echo "" podman exec "$CONTAINER" php "$ANALYZER" "$profile_file" } cmd_analyze() { local file="$1" ensure_analyzer if [ -z "$file" ]; then file=$(podman exec "$CONTAINER" sh -c 'ls -t /tmp/cachegrind.out.* 2>/dev/null | head -1') if [ -z "$file" ]; then echo -e "${RED}No cachegrind files found. Run a profile first.${NC}" exit 1 fi echo -e "${YELLOW}Analyzing: $file${NC}" fi podman exec "$CONTAINER" php "$ANALYZER" "$file" } cmd_list() { echo -e "${GREEN}Available cachegrind profiles in $CONTAINER:${NC}" if ! podman exec "$CONTAINER" sh -c 'ls -lh /tmp/cachegrind.out.* 2>/dev/null'; then echo -e "${YELLOW}No profile files found.${NC}" fi } cmd_clean() { echo -e "${YELLOW}Removing cachegrind files from $CONTAINER...${NC}" podman exec "$CONTAINER" sh -c 'rm -f /tmp/cachegrind.out.*' 2>/dev/null || true echo -e "${GREEN}Done!${NC}" } cmd_generate() { local size="${1:-medium}" local categories posts depth case "$size" in small) categories=5; posts=20; depth=2 ;; medium) categories=10; posts=50; depth=3 ;; large) categories=15; posts=100; depth=3 ;; huge) categories=25; posts=200; depth=4 ;; custom) categories=${2:-10}; posts=${3:-50}; depth=${4:-3} ;; *) echo -e "${RED}Unknown size. Use: small, medium, large, huge, custom${NC}"; exit 1 ;; esac echo -e "${GREEN}Generating $size test data in $CONTAINER:/tmp/test-content${NC}" echo -e "${BLUE}Config: $categories categories, $posts posts/category, $depth levels${NC}" # Create generator script in container cat << 'EOFGEN' | podman exec -i "$CONTAINER" tee /tmp/gen.php > /dev/null $maxDepth) return; $numCategories = $depth === 0 ? 10 : rand(3, 8); for ($c = 0; $c < $numCategories; $c++) { $categoryName = $categories[array_rand($categories)] . '-' . rand(100, 999); $categoryPath = $baseDir . $parentPath . '/' . $categoryName; if (!is_dir($categoryPath)) mkdir($categoryPath, 0755, true); $categoryTitle = ucwords(str_replace('-', ' ', $categoryName)); file_put_contents("$categoryPath/metadata.ini", generateMetadata($categoryTitle, true)); if (rand(0, 1)) { file_put_contents("$categoryPath/index.md", generateMarkdown("Welcome to $categoryTitle", 3)); } $numPosts = $depth === $maxDepth ? rand(5, $postsPerCategory) : rand(5, 15); for ($p = 0; $p < $numPosts; $p++) { $title = $titles[array_rand($titles)] . ' ' . $topics[array_rand($topics)]; $date = date('Y-m-d', strtotime('-' . rand(1, 730) . ' days')); $slug = generateSlug($title); $postDir = "$categoryPath/$date-$slug"; if (!is_dir($postDir)) mkdir($postDir, 0755, true); file_put_contents("$postDir/index.md", generateMarkdown($title, rand(3, 8))); if (rand(0, 2) === 0) { file_put_contents("$postDir/metadata.ini", generateMetadata($title, false)); } } if ($depth < $maxDepth && rand(0, 1)) { generateContent($baseDir, $depth + 1, $maxDepth, $postsPerCategory, $parentPath . '/' . $categoryName); } } } if (is_dir($baseDir)) exec("rm -rf $baseDir"); mkdir($baseDir, 0755, true); file_put_contents("$baseDir/00-intro.md", generateMarkdown("Test Site", 2)); echo "Generating test data...\n"; $start = microtime(true); generateContent($baseDir, 0, $maxDepth, $postsPerCategory); $duration = round(microtime(true) - $start, 2); $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($baseDir, RecursiveDirectoryIterator::SKIP_DOTS)); $fileCount = 0; foreach ($iterator as $file) if ($file->isFile()) $fileCount++; echo "\nGenerated $fileCount files in {$duration}s\n"; echo "Location: $baseDir\n"; EOFGEN # Run generator podman exec "$CONTAINER" php /tmp/gen.php "$categories" "$posts" "$depth" echo -e "\n${GREEN}Test data created at /tmp/test-content inside container${NC}" echo -e "${YELLOW}To use: Update app/router.php to use /tmp/test-content${NC}" echo -e "${YELLOW}Or profile directly: $0 profile /blog-123/${NC}" } cmd_testdata_stats() { if ! podman exec "$CONTAINER" test -d /tmp/test-content 2>/dev/null; then echo -e "${RED}No test data found in $CONTAINER:/tmp/test-content${NC}" echo -e "${YELLOW}Generate with: $0 generate [size]${NC}" exit 1 fi echo -e "${GREEN}Test Data Statistics ($CONTAINER:/tmp/test-content):${NC}\n" podman exec "$CONTAINER" bash -c ' cd /tmp/test-content echo "Directories: $(find . -type d | wc -l)" echo "Total files: $(find . -type f | wc -l)" echo "Markdown files: $(find . -name "*.md" | wc -l)" echo "Metadata files: $(find . -name "*.ini" | wc -l)" echo "Total size: $(du -sh . | cut -f1)" ' } cmd_testdata_clean() { echo -e "${YELLOW}Removing test data from $CONTAINER:/tmp/test-content...${NC}" podman exec "$CONTAINER" rm -rf /tmp/test-content 2>/dev/null || true echo -e "${GREEN}Done!${NC}" } # Main command dispatcher case "${1:-help}" in profile) cmd_profile "$2" ;; analyze) cmd_analyze "$2" ;; list) cmd_list ;; clean) cmd_clean ;; generate|gen) shift; cmd_generate "$@" ;; testdata-stats|stats) cmd_testdata_stats ;; testdata-clean) cmd_testdata_clean ;; help|--help|-h) show_help ;; *) echo -e "${RED}Unknown command: $1${NC}\n"; show_help; exit 1 ;; esac