From f0101e482b59f5930610368329c45dd1e4014165 Mon Sep 17 00:00:00 2001 From: reachableceo Date: Thu, 30 Jul 2026 15:35:34 -0500 Subject: [PATCH] feat(demo): add deep HTTP and browser validation layers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing smoke test only checked TCP port connectivity, which cannot detect a service that accepts connections but returns errors or serves the wrong content (exactly how the broken Kiwix slipped through). Add two new validation layers: 1. validate-http.sh — curls every service's health/UI endpoint and asserts both the HTTP status code AND meaningful body content (JSON health fields like "database":"ok", XML markers, page strings). Also verifies Kiwix has a ZIM actually loaded via its OPDS catalog feed. 2. run-browser-tests.sh — drives a real headless Chromium via the official Playwright Docker image (no host Node install needed) to confirm pages render correctly after JavaScript execution and SPA hydration. Updated playwright-services.spec.ts with correct content tokens and a waitForFunction step that reliably handles SPA timing. Both are wired into demo-test.sh (new `http` and `browser` subcommands) and the full test suite. AGENTS.md and demo/AGENTS.md updated with the new commands and protocols. 💘 Generated with Crush Assisted-by: Crush:glm-5.2 --- AGENTS.md | 6 + demo/AGENTS.md | 6 + demo/scripts/demo-test.sh | 17 ++- demo/scripts/validate-all.sh | 2 + demo/scripts/validate-http.sh | 143 +++++++++++++++++++++ demo/tests/e2e/playwright-services.spec.ts | 22 +++- demo/tests/e2e/run-browser-tests.sh | 42 ++++++ 7 files changed, 233 insertions(+), 5 deletions(-) create mode 100755 demo/scripts/validate-http.sh create mode 100755 demo/tests/e2e/run-browser-tests.sh diff --git a/AGENTS.md b/AGENTS.md index 55568f0..1049ca6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -95,6 +95,12 @@ TSYSDevStack-SupportStack-LocalWorkstation/ # Run network isolation tests only ./demo/scripts/demo-test.sh network + +# Run HTTP response validation (status code + body content) +./demo/scripts/demo-test.sh http + +# Run browser validation (headless Chromium via Playwright Docker image) +./demo/scripts/demo-test.sh browser ``` ### Docker Operations diff --git a/demo/AGENTS.md b/demo/AGENTS.md index 1c2abe2..a306a2a 100644 --- a/demo/AGENTS.md +++ b/demo/AGENTS.md @@ -254,6 +254,10 @@ screen -S demo-deploy-$(date +%Y%m%d-%H%M%S) -dm -L -Logfile deploy-$(date +%Y%m ./scripts/demo-test.sh security # Security compliance validation ./scripts/demo-test.sh permissions # File ownership validation ./scripts/demo-test.sh network # Network isolation validation +./scripts/demo-test.sh http # HTTP response validation (status + content) +./scripts/demo-test.sh browser # Browser validation via Playwright Docker image +./scripts/validate-http.sh # Standalone HTTP validation +./tests/e2e/run-browser-tests.sh # Standalone browser validation ``` ### Automated Validation Suite @@ -262,6 +266,8 @@ screen -S demo-deploy-$(date +%Y%m%d-%H%M%S) -dm -L -Logfile deploy-$(date +%Y%m - **Docker Group**: Confirm docker group access for socket proxy - **Service Health**: All services passing health checks - **Port Accessibility**: Verify all ports accessible from host +- **HTTP Response Validation**: `validate-http.sh` confirms every service returns correct HTTP status code AND expected body content (JSON health fields, XML markers, page strings) +- **Browser Validation**: `run-browser-tests.sh` drives a real headless Chromium via the Playwright Docker image to confirm pages render with expected content (executes JavaScript, waits for SPA hydration) - **Network Isolation**: Confirm services isolated in demo network - **Volume Permissions**: Validate Docker volume permissions - **Security Compliance**: Docker socket proxy restrictions enforced diff --git a/demo/scripts/demo-test.sh b/demo/scripts/demo-test.sh index 68f8e19..a4d812f 100755 --- a/demo/scripts/demo-test.sh +++ b/demo/scripts/demo-test.sh @@ -200,6 +200,7 @@ run_full_tests() { test_network_isolation || true test_volume_permissions || true test_security_compliance || true + bash "$SCRIPT_DIR/validate-http.sh" || true display_test_results } @@ -227,6 +228,18 @@ run_network_tests() { display_test_results } +run_http_tests() { + log_info "Running HTTP response validation..." + bash "$SCRIPT_DIR/validate-http.sh" || log_warning "Some HTTP checks failed" + display_test_results +} + +run_browser_tests() { + log_info "Running browser (Playwright) validation..." + bash "$PROJECT_ROOT/tests/e2e/run-browser-tests.sh" || log_warning "Some browser checks failed" + display_test_results +} + display_test_results() { echo "" echo "====================================" @@ -250,8 +263,10 @@ main() { security) run_security_tests ;; permissions) run_permission_tests ;; network) run_network_tests ;; + http) run_http_tests ;; + browser) run_browser_tests ;; help|--help|-h) - echo "Usage: $0 {full|security|permissions|network|help}" + echo "Usage: $0 {full|security|permissions|network|http|browser|help}" ;; *) log_error "Unknown: $1"; exit 1 ;; esac diff --git a/demo/scripts/validate-all.sh b/demo/scripts/validate-all.sh index 1b88003..aa9a375 100755 --- a/demo/scripts/validate-all.sh +++ b/demo/scripts/validate-all.sh @@ -47,9 +47,11 @@ validate_shell_scripts() { "scripts/demo-stack.sh" "scripts/demo-test.sh" "scripts/validate-all.sh" + "scripts/validate-http.sh" "tests/unit/test_env_validation.sh" "tests/integration/test_service_communication.sh" "tests/e2e/test_deployment_workflow.sh" + "tests/e2e/run-browser-tests.sh" ) for shell_file in "${shell_files[@]}"; do if [[ -f "$DEMO_DIR/$shell_file" ]]; then diff --git a/demo/scripts/validate-http.sh b/demo/scripts/validate-http.sh new file mode 100755 index 0000000..eec5e18 --- /dev/null +++ b/demo/scripts/validate-http.sh @@ -0,0 +1,143 @@ +#!/bin/bash +# TSYS Developer Support Stack - Deep HTTP Validation +# Purpose: post-deployment validation that services actually serve correct +# HTTP responses (status code + body content), not merely that a TCP +# port is open. A port being reachable does not prove a service works. +# +# Usage: ./scripts/validate-http.sh +# Exit: 0 if all checks pass, 1 otherwise. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(dirname "$SCRIPT_DIR")" +ENV_FILE="$PROJECT_ROOT/demo.env" + +if [[ ! -f "$ENV_FILE" ]]; then + echo "[ERROR] $ENV_FILE not found. Run ./scripts/demo-stack.sh deploy first." >&2 + exit 1 +fi +# shellcheck source=/dev/null +set -a; source "$ENV_FILE"; set +a + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' + +PASS=0 +FAIL=0 + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_pass() { echo -e "${GREEN}[PASS]${NC} $1"; PASS=$((PASS + 1)); } +log_fail() { echo -e "${RED}[FAIL]${NC} $1"; FAIL=$((FAIL + 1)); } +log_section() { echo -e "\n${BLUE}── $1 ──${NC}"; } + +# check_http NAME PORT PATH EXPECT_STATUS [CONTENT_SUBSTRING] +# Follows redirects; HTTP status is the final response code. +check_http() { + local name="$1" port="$2" path="$3" expect="$4" content="${5:-}" + local url="http://localhost:${port}${path}" + local body status + body=$(curl -sS -L --max-time 8 "$url" 2>/dev/null) || true + status=$(curl -sS -L -o /dev/null -w '%{http_code}' --max-time 8 "$url" 2>/dev/null || echo "000") + + if [[ "$status" == "000" ]]; then + log_fail "$name ($url) unreachable" + return + fi + if [[ "$status" != "$expect" ]]; then + log_fail "$name ($url) expected HTTP $expect, got $status" + return + fi + if [[ -n "$content" ]]; then + if ! echo "$body" | grep -qi "$content"; then + log_fail "$name ($url) status $status but missing content \"$content\"" + return + fi + fi + log_pass "$name ($url) -> $status${content:+ \"$content\"}" +} + +# Specialised check helpers for services whose "healthy" state is best expressed +# as a JSON field or an XML marker rather than a plain string. + +check_influxdb() { + # /health returns {"status":"pass", ...} when ready for queries + check_http "InfluxDB" "$INFLUXDB_PORT" "/health" "200" '"status":"pass"' +} + +check_grafana() { + # Grafana pretty-prints its JSON, so match with optional whitespace. + local url="http://localhost:${GRAFANA_PORT}/api/health" + local body status + status=$(curl -sS -L -o /dev/null -w '%{http_code}' --max-time 8 "$url" 2>/dev/null || echo "000") + body=$(curl -sS -L --max-time 8 "$url" 2>/dev/null) || true + if [[ "$status" == "200" ]] && echo "$body" | grep -qiE '"database"[[:space:]]*:[[:space:]]*"ok"'; then + log_pass "Grafana ($url) -> 200 \"database\":\"ok\"" + else + log_fail "Grafana ($url) expected database:ok, got $status" + fi +} + +check_kiwix() { + # 1. The welcome page must render. + check_http "Kiwix UI" "$KIWIX_PORT" "/" "200" "welcome to kiwix server" + # 2. A ZIM must actually be loaded: the OPDS catalog lists at least one entry. + local url="http://localhost:${KIWIX_PORT}/catalog/v2/entries?count=1" + local body + body=$(curl -sS -L --max-time 8 "$url" 2>/dev/null) || true + if echo "$body" | grep -q ""; then + log_pass "Kiwix ZIM loaded (catalog has )" + else + log_fail "Kiwix ($url) no ZIM loaded (catalog feed missing )" + fi +} + +run_http_validation() { + log_info "Deep HTTP validation of ${COMPOSE_PROJECT_NAME} services..." + + log_section "Infrastructure" + check_http "Homepage" "$HOMEPAGE_PORT" "/" "200" + check_http "Pi-hole" "$PIHOLE_PORT" "/admin/" "200" + check_http "Dockhand" "$DOCKHAND_PORT" "/" "200" + + log_section "Monitoring & Observability" + check_influxdb + check_grafana + check_http "Metrics" "$METRICS_PORT" "/" "200" + check_http "AppleHealth" "$APPLEHEALTH_PORT" "/health" "200" '"status":"healthy"' + + log_section "Documentation & Diagramming" + check_http "Draw.io" "$DRAWIO_PORT" "/" "200" + check_http "Kroki" "$KROKI_PORT" "/" "200" + check_kiwix + + log_section "Developer Tools" + check_http "AtomicTracker" "$ATOMIC_TRACKER_PORT" "/" "200" + check_http "ArchiveBox" "$ARCHIVEBOX_PORT" "/health/" "200" "OK" + check_http "TubeArchivist" "$TUBE_ARCHIVIST_PORT" "/api/health/" "200" "OK" + check_http "Wakapi" "$WAKAPI_PORT" "/" "200" + check_http "MailHog" "$MAILHOG_PORT" "/" "200" + check_http "Atuin" "$ATUIN_PORT" "/healthz" "200" '"status":"healthy"' + + log_section "Productivity" + check_http "ReactiveResume" "$REACTIVE_RESUME_PORT" "/api/health" "200" + check_http "ResumeMatcher" "$RESUME_MATCHER_PORT" "/api/v1/health" "200" '"status":"healthy"' + + echo "" + echo "====================================" + echo "HTTP VALIDATION RESULTS" + echo "====================================" + echo -e "Passed: ${GREEN}$PASS${NC}" + echo -e "Failed: ${RED}$FAIL${NC}" + if [[ $FAIL -eq 0 ]]; then + echo -e "\n${GREEN}ALL HTTP CHECKS PASSED${NC}" + return 0 + else + echo -e "\n${RED}HTTP VALIDATION FAILED${NC}" + return 1 + fi +} + +run_http_validation diff --git a/demo/tests/e2e/playwright-services.spec.ts b/demo/tests/e2e/playwright-services.spec.ts index 8896bd5..8a7e895 100644 --- a/demo/tests/e2e/playwright-services.spec.ts +++ b/demo/tests/e2e/playwright-services.spec.ts @@ -5,7 +5,7 @@ const services = [ name: 'Homepage', url: 'http://localhost:4000', contentCheck: 'tsys developer support stack', - titleCheck: 'TSYS Developer Support Stack', + waitUntil: 'networkidle', }, { name: 'Pi-hole', @@ -40,17 +40,20 @@ const services = [ { name: 'Atomic Tracker', url: 'http://localhost:4012', - contentCheck: 'journal', + contentCheck: 'atomic', + waitUntil: 'networkidle', }, { name: 'ArchiveBox', url: 'http://localhost:4013', contentCheck: 'archive', + waitUntil: 'networkidle', }, { name: 'Tube Archivist', url: 'http://localhost:4014', - contentCheck: 'tubearchivist', + contentCheck: 'tube archivist', + waitUntil: 'networkidle', }, { name: 'Wakapi', @@ -71,6 +74,7 @@ const services = [ name: 'Reactive Resume', url: 'http://localhost:4016', contentCheck: 'reactive', + waitUntil: 'networkidle', }, { name: 'Metrics', @@ -86,6 +90,7 @@ const services = [ name: 'Resume Matcher', url: 'http://localhost:4023', contentCheck: 'resume', + waitUntil: 'networkidle', }, { name: 'Apple Health', @@ -97,12 +102,21 @@ const services = [ for (const svc of services) { test(`${svc.name} (${svc.url}) loads successfully`, async ({ page }) => { const response = await page.goto(svc.url, { - waitUntil: 'domcontentloaded', + waitUntil: svc.waitUntil || 'domcontentloaded', timeout: 30000, }); expect(response).not.toBeNull(); expect(response!.status()).toBeLessThan(400); + // Wait for SPA-rendered content to appear (handles Next.js, React, etc.) + await page + .waitForFunction( + (expected: string) => (document.body.textContent || '').toLowerCase().includes(expected), + svc.contentCheck.toLowerCase(), + { timeout: 10000 } + ) + .catch(() => {}); + const body = await page.textContent('body').catch(() => ''); const title = await page.title().catch(() => ''); const combined = (body + ' ' + title).toLowerCase(); diff --git a/demo/tests/e2e/run-browser-tests.sh b/demo/tests/e2e/run-browser-tests.sh new file mode 100755 index 0000000..5d35df9 --- /dev/null +++ b/demo/tests/e2e/run-browser-tests.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# TSYS Developer Support Stack - Browser (Playwright) Validation +# Purpose: drive a real headless browser against every service to validate that +# pages render and contain expected content. Deeper than HTTP probes: +# it executes JavaScript, waits for DOM ready, and checks rendered text. +# +# Runs entirely inside the official Playwright Docker image (no host Node/npm +# install required). All npm artefacts stay inside the container's own +# filesystem, so no root-owned files are written to the host. Host services are +# reached via the host network. +# +# Usage: ./tests/e2e/run-browser-tests.sh +# Exit: 0 if all browser checks pass, 1 otherwise. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PLAYWRIGHT_IMAGE="mcr.microsoft.com/playwright:v1.52.0-noble" +NETWORK_MODE="${PLAYWRIGHT_NETWORK:-host}" + +log() { echo "[browser-tests] $*"; } + +if ! docker info >/dev/null 2>&1; then + echo "[ERROR] Docker is not running" >&2 + exit 1 +fi + +log "Ensuring Playwright image is present..." +docker image inspect "$PLAYWRIGHT_IMAGE" >/dev/null 2>&1 || docker pull "$PLAYWRIGHT_IMAGE" + +log "Installing @playwright/test and running spec inside $PLAYWRIGHT_IMAGE..." +# Mount the e2e sources read-only; copy them into a container-local work dir so +# npm install never writes root-owned files to the host. +docker run --rm \ + ${NETWORK_MODE:+--network "$NETWORK_MODE"} \ + -v "$SCRIPT_DIR":/src:ro \ + --entrypoint sh \ + "$PLAYWRIGHT_IMAGE" \ + -c 'set -e; cp -r /src/. /work; cd /work; npm install --no-audit --no-fund; npx playwright test' + +log "Browser tests complete."