From 33be2380736c1cd4b7f7d292a24733fd1daafb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E0=B8=98=E0=B8=93=E0=B8=A7=E0=B8=B1=E0=B8=92=E0=B8=99?= =?UTF-8?q?=E0=B9=8C=20=E0=B8=9B=E0=B8=A3=E0=B8=B0=E0=B8=A7=E0=B8=B1?= =?UTF-8?q?=E0=B8=99=E0=B8=95=E0=B8=B2?= Date: Wed, 6 May 2026 18:05:29 +0700 Subject: [PATCH 1/6] Document meechain-connect named tunnel cutover --- README.md | 75 ++++++- scripts/test-production.sh | 386 +++++++++++++++++++++++++++++++++++++ 2 files changed, 460 insertions(+), 1 deletion(-) create mode 100755 scripts/test-production.sh diff --git a/README.md b/README.md index e296a4d..ff76409 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,80 @@ Passing all checks earns the contributor the **RPC Ready Badge** as part of the bash scripts/test-rpc.sh https://rpc.meechain.live/rpc https://rpc.meechain.live/health ``` -ถ้าอยู่หลัง Cloudflare Access: +## Production post-deploy validation +ใช้หลัง `docker compose up -d` เพื่อตรวจ health endpoint, RPC proxy, network config, Web3 status และ DNS พื้นฐานในคำสั่งเดียว: + +```bash +npm run test:prod -- https://rpc.meechain.live +``` + +หลังเปลี่ยน DNS/CNAME ให้ `rpc.meechain.live` ชี้ไปที่ tunnel ชั่วคราว เช่น `speaker-marshall-stations-antonio.trycloudflare.com` แล้ว ให้ทดสอบผ่านชื่อจริงที่ผู้ใช้จะเรียก (`rpc.meechain.live`) เป็นหลัก. ถ้าเปิด proxy สีส้มของ Cloudflare อยู่ ภายนอกอาจเห็นเป็น IP ของ Cloudflare แทน CNAME target ซึ่งเป็นพฤติกรรมปกติ. + +> หมายเหตุ: URL แบบ `*.trycloudflare.com` จาก quick tunnel จะเปลี่ยนทุกครั้งเมื่อกด Stop/Start หรือ Restart ดังนั้นให้ถือเป็นช่องทางชั่วคราวเท่านั้น และต้องอัปเดต CNAME/เป้าหมาย tunnel ให้ตรงกับ URL ล่าสุดเสมอ. ถ้าต้องการ URL คงที่ในระยะยาว ควรใช้ named Cloudflare Tunnel กับ hostname `rpc.meechain.live` โดยตรง. + +### เปลี่ยนช่องทางไปที่ Named Tunnel `meechain-connect` + +สำหรับ production ให้ใช้ Named Tunnel `meechain-connect` แทน Quick Tunnel เพื่อให้ `rpc.meechain.live` เป็นช่องทางคงที่และไม่เปลี่ยนทุกครั้งที่ restart: + +1. แก้ DNS record `rpc.meechain.live` จาก Quick Tunnel ปัจจุบัน เช่น `exploration-garmin-guided-tower.trycloudflare.com` ไปที่ Named Tunnel target: + + ```text + 74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com + ``` + +2. บนเครื่องที่รัน origin ให้รัน named tunnel แทน quick tunnel: + + ```bash + cloudflared tunnel run meechain-connect + ``` + +3. ตรวจหลัง cutover ผ่าน hostname จริง พร้อมเช็ก CNAME target ของ Named Tunnel: + + ```bash + EXPECTED_CNAME_TARGET=74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com \ + EXPECT_UPSTREAM_CONNECTED=1 \ + npm run test:prod -- https://rpc.meechain.live + ``` + +ถ้า Cloudflare DNS เปิด proxy สีส้มอยู่ CNAME target อาจถูกซ่อนหลัง Cloudflare edge IP ทำให้ CLI ภายนอกเห็นเฉพาะ A/AAAA record; ให้ยืนยัน target ใน Cloudflare dashboard หรือปิด proxy เป็น DNS-only ชั่วคราวตอนตรวจ cutover แล้วค่อยเปิดกลับ. ถ้าต้องการให้ script fail เมื่อมองไม่เห็น CNAME หรือ target ไม่ตรง ให้เพิ่ม `EXPECTED_CNAME_STRICT=1`. + +ถ้าใช้ URL ชั่วคราวจาก `trycloudflare.com` หรือแอป Pocket Live Server ให้ใช้ origin เป็นหลัก เช่น `https://speaker-marshall-stations-antonio.trycloudflare.com` (ไม่ต้องใส่ `/favicon.ico`). ถ้าเผลอวาง URL ที่มี path เช่น `/favicon.ico` สคริปต์จะ normalize กลับไปตรวจที่ origin ให้อัตโนมัติ: + +```bash +npm run test:prod -- https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico +``` + +ถ้ามีช่องทางปัจจุบันและช่องทางรองที่ต้องตรวจพร้อมกัน ให้ใช้ `--also` หรือ `PROD_TEST_ADDITIONAL_URLS`: + +```bash +npm run test:prod -- https://rpc.meechain.live --also https://explicitly-browse-placement.trycloudflare.com +PROD_TEST_ADDITIONAL_URLS="https://explicitly-browse-placement.trycloudflare.com,https://molecules-peripheral-accepts-bench.trycloudflare.com" npm run test:prod -- https://rpc.meechain.live +``` + +ถ้าเปิด URL จากแอปแล้วเห็นหน้า MeeChain Dashboard ได้ แปลว่า app tunnel เส้นนั้นกำลังชี้เข้าถึง frontend ได้แล้ว. แต่ถ้าแถบบนขึ้น `RPC upstream ยังไม่พร้อม — ใช้ local/mock data` หรือ `Upstream: Degraded` ให้ตรวจ `/api/web3/status` และ `/rpc/health` เพิ่ม เพราะ frontend online แล้วแต่ upstream RPC อาจยังไม่พร้อม. หากต้องการให้ validation fail ทันทีเมื่อ upstream degraded ให้ตั้ง `EXPECT_UPSTREAM_CONNECTED=1`: + +```bash +EXPECT_UPSTREAM_CONNECTED=1 npm run test:prod -- https://exploration-garmin-guided-tower.trycloudflare.com/. +``` + +ถ้า log ของ `cloudflared` ขึ้น `Unable to reach the origin service ... dial tcp 127.0.0.1:8000: connect: connection refused` ให้ตรวจว่า local service เปิดอยู่ที่ port เดียวกับที่ tunnel ตั้งไว้ก่อนรัน validation เช่น Pocket Live Server เลือก port `8000` ก็ต้องมีแอปรอรับที่ `127.0.0.1:8000`. + +ตรวจผ่าน nginx/Cloudflare tunnel บนเครื่อง production ที่ expose HTTPS แบบ local self-signed certificate ได้ด้วย: + +```bash +npm run test:prod:local +# equivalent: bash scripts/test-production.sh https://localhost:8445 --insecure --skip-network +``` + +ถ้า endpoint อยู่หลัง Cloudflare Access ให้ตั้ง service token ก่อนรัน: + +```bash +export CF_ACCESS_CLIENT_ID="" +export CF_ACCESS_CLIENT_SECRET="" +npm run test:prod -- https://rpc.meechain.live +``` + +สำหรับ RPC smoke test เดิม ถ้าอยู่หลัง Cloudflare Access ให้ใช้ service token ชุดเดียวกัน: ```bash export CF_ACCESS_CLIENT_ID="" export CF_ACCESS_CLIENT_SECRET="" diff --git a/scripts/test-production.sh b/scripts/test-production.sh new file mode 100755 index 0000000..18bae57 --- /dev/null +++ b/scripts/test-production.sh @@ -0,0 +1,386 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Production post-deploy validation for MeeChain Connect. + +usage() { + cat <<'USAGE' +Usage: + bash scripts/test-production.sh [base_url] [--also url] [--insecure] [--skip-app] [--skip-network] [--timeout seconds] + +Examples: + bash scripts/test-production.sh https://rpc.meechain.live + bash scripts/test-production.sh https://rpc.meechain.live --also https://explicitly-browse-placement.trycloudflare.com + bash scripts/test-production.sh https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico + bash scripts/test-production.sh https://localhost:8445 --insecure --skip-network + EXPECT_UPSTREAM_CONNECTED=1 bash scripts/test-production.sh https://rpc.meechain.live + EXPECTED_CNAME_TARGET=74d57437-86f8-47ee-91fa-5260b04a5ba3.cfargotunnel.com bash scripts/test-production.sh https://rpc.meechain.live + +Notes: + Pass the public origin that clients use, for example https://rpc.meechain.live. + Temporary trycloudflare.com quick-tunnel URLs rotate after stop/restart, so update + the DNS/CNAME target and any --also URL to the currently active tunnel. + If you paste an asset URL such as /favicon.ico from a tunnel app, the script + automatically normalizes it to the origin before checking endpoints. + Set PROD_TEST_ADDITIONAL_URLS to a comma-separated list to validate standby URLs. + Set EXPECT_UPSTREAM_CONNECTED=1 to fail when /api/web3/status reports degraded. + Set EXPECTED_CNAME_TARGET to verify a named Cloudflare Tunnel CNAME target. + Set EXPECTED_CNAME_STRICT=1 to fail instead of warn when CNAME is hidden/mismatched. +USAGE +} + +BASE_URL="${1:-https://rpc.meechain.live}" +if [[ "$BASE_URL" == --* ]]; then + BASE_URL="https://rpc.meechain.live" +else + shift || true +fi + +TIMEOUT="${PROD_TEST_TIMEOUT:-10}" +EXPECTED_CHAIN_ID_HEX="${EXPECTED_CHAIN_ID_HEX:-0x344e}" +EXPECTED_CHAIN_ID_DEC="${EXPECTED_CHAIN_ID_DEC:-13390}" +EXPECTED_APP_TEXT="${EXPECTED_APP_TEXT:-MeeChain}" +EXPECT_UPSTREAM_CONNECTED="${EXPECT_UPSTREAM_CONNECTED:-0}" +EXPECTED_CNAME_TARGET="${EXPECTED_CNAME_TARGET:-}" +EXPECTED_CNAME_STRICT="${EXPECTED_CNAME_STRICT:-0}" +CF_ACCESS_CLIENT_ID="${CF_ACCESS_CLIENT_ID:-}" +CF_ACCESS_CLIENT_SECRET="${CF_ACCESS_CLIENT_SECRET:-}" +INSECURE=0 +SKIP_NETWORK=0 +SKIP_APP=0 +ADDITIONAL_BASE_URLS=() +if [[ -n "${PROD_TEST_ADDITIONAL_URLS:-}" ]]; then + IFS=',' read -r -a ADDITIONAL_BASE_URLS <<< "$PROD_TEST_ADDITIONAL_URLS" +fi + +while [[ $# -gt 0 ]]; do + case "$1" in + --insecure|-k) + INSECURE=1 + shift + ;; + --skip-network) + SKIP_NETWORK=1 + shift + ;; + --skip-app) + SKIP_APP=1 + shift + ;; + --also) + ADDITIONAL_BASE_URLS+=("${2:?--also requires a URL}") + shift 2 + ;; + --timeout) + TIMEOUT="${2:?--timeout requires a value}" + shift 2 + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "Unknown option: $1" >&2 + exit 2 + ;; + esac +done + +TMP_DIR="$(mktemp -d)" +HEADERS="$TMP_DIR/headers" +BODY="$TMP_DIR/body" +trap 'rm -rf "$TMP_DIR"' EXIT + +pass() { echo "✅ $*"; } +fail() { echo "❌ $*" >&2; exit 1; } +warn() { echo "⚠️ $*"; } +info() { echo "ℹ️ $*"; } + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + fail "required command not found: $cmd" + fi +} + +normalize_url() { + local raw="$1" + local original="${raw%/}" + local normalized + if ! normalized="$(node -e "const u = new URL(process.argv[1]); console.log(u.origin);" "$raw" 2>/dev/null)"; then + fail "invalid base URL: $raw" + fi + if [[ "$normalized" != "$original" ]]; then + info "base URL included a path/query/fragment; using origin: $normalized" >&2 + fi + printf '%s\n' "$normalized" +} + +CURL_ARGS=(-sS -m "$TIMEOUT") +if [[ "$INSECURE" -eq 1 ]]; then + CURL_ARGS+=(-k) +fi + +ACCESS_HEADERS=() +if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then + ACCESS_HEADERS+=(-H "CF-Access-Client-Id: ${CF_ACCESS_CLIENT_ID}") + ACCESS_HEADERS+=(-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}") +fi + +status_code() { + awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" +} + +assert_no_access_redirect() { + local location + location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" + if [[ "${location:-}" == *"cloudflareaccess.com"* ]]; then + fail "blocked by Cloudflare Access redirect: $location" + fi +} + +curl_get() { + local url="$1" + if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" "${ACCESS_HEADERS[@]}" "$url"; then + fail "GET failed for $url" + fi +} + +curl_post_json() { + local url="$1" + local payload="$2" + if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" \ + "${ACCESS_HEADERS[@]}" \ + -H 'content-type: application/json' \ + --data "$payload" \ + "$url"; then + fail "POST JSON failed for $url" + fi +} + +assert_http_200() { + local label="$1" + local code + code="$(status_code)" + assert_no_access_redirect + if [[ "$code" != "200" ]]; then + echo "--- response headers ---" >&2 + cat "$HEADERS" >&2 + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label returned HTTP ${code:-n/a}, expected 200" + fi + pass "$label HTTP 200" +} + +json_field() { + local expression="$1" + node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" +} + +assert_json_field_equals() { + local label="$1" + local expression="$2" + local expected="$3" + local actual + actual="$(json_field "$expression")" || fail "$label missing or invalid JSON field" + if [[ "$actual" != "$expected" ]]; then + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label expected '$expected' but got '$actual'" + fi + pass "$label = $actual" +} + +assert_json_field_present() { + local label="$1" + local expression="$2" + json_field "$expression" >/dev/null || fail "$label missing or invalid JSON field" + pass "$label present" +} + +assert_json_field_truthy_or_warn() { + local label="$1" + local expression="$2" + local actual + actual="$(json_field "$expression")" || fail "$label missing or invalid JSON field" + if [[ "$actual" == "true" ]]; then + pass "$label = true" + elif [[ "$EXPECT_UPSTREAM_CONNECTED" == "1" ]]; then + echo "--- response body ---" >&2 + cat "$BODY" >&2 + fail "$label expected true but got '$actual'" + else + warn "$label = $actual (degraded; set EXPECT_UPSTREAM_CONNECTED=1 to fail)" + fi +} + +assert_body_contains() { + local label="$1" + local expected="$2" + if ! node -e "const fs=require('fs'); const body=fs.readFileSync(process.argv[1], 'utf8'); process.exit(body.includes(process.argv[2]) ? 0 : 1);" "$BODY" "$expected"; then + echo "--- response body preview ---" >&2 + head -c 500 "$BODY" >&2 || true + echo >&2 + fail "$label did not contain expected text: $expected" + fi + pass "$label contains '$expected'" +} + +check_get_json() { + local path="$1" + local label="$2" + info "GET ${BASE_URL}${path}" + curl_get "${BASE_URL}${path}" + assert_http_200 "$label" +} + + +check_app_shell() { + if [[ "$SKIP_APP" -eq 1 ]]; then + info "skipping app shell check (--skip-app)" + return + fi + + check_get_json "/" "app shell" + assert_body_contains "app shell" "$EXPECTED_APP_TEXT" +} + +check_health_endpoints() { + check_get_json "/health" "root health" + assert_json_field_equals "root health status" "data.status" "ok" + assert_json_field_present "root health version" "data.version" + + check_get_json "/api/health" "API health" + assert_json_field_equals "API health status" "data.status" "ok" + + check_get_json "/rpc/health" "RPC health" + assert_json_field_equals "RPC health status" "data.status" "ok" + assert_json_field_equals "RPC health chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_DEC" + assert_json_field_present "RPC health mode" "data.mode" +} + +check_rpc_proxy() { + info "POST ${BASE_URL}/rpc eth_chainId" + curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' + assert_http_200 "RPC eth_chainId" + assert_json_field_equals "RPC eth_chainId result" "data.result" "$EXPECTED_CHAIN_ID_HEX" + + info "POST ${BASE_URL}/rpc eth_blockNumber" + curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":2,"method":"eth_blockNumber","params":[]}' + assert_http_200 "RPC eth_blockNumber" + assert_json_field_present "RPC eth_blockNumber result" "data.result" +} + +check_network_config() { + check_get_json "/api/network" "network config" + assert_json_field_equals "network chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_HEX" + assert_json_field_equals "network chainIdDecimal" "String(data.chainIdDecimal)" "$EXPECTED_CHAIN_ID_DEC" + assert_json_field_equals "network first rpcUrl" "data.rpcUrls && data.rpcUrls[0]" "${BASE_URL}/rpc" + + check_get_json "/api/web3/status" "web3 status" + assert_json_field_truthy_or_warn "web3 status connected flag" "data.connected" +} + + +normalize_dns_name() { + local name="$1" + name="${name%.}" + printf '%s\n' "${name,,}" +} + +check_expected_cname_target() { + local host="$1" + [[ -n "$EXPECTED_CNAME_TARGET" ]] || return + + local expected + expected="$(normalize_dns_name "$EXPECTED_CNAME_TARGET")" + local targets="" + if command -v dig >/dev/null 2>&1; then + targets="$(dig +short CNAME "$host" 2>/dev/null | sed 's/\.$//' | tr '[:upper:]' '[:lower:]' || true)" + elif command -v nslookup >/dev/null 2>&1; then + targets="$(nslookup -type=CNAME "$host" 2>/dev/null | awk -F'= ' '/canonical name/ {print $2}' | sed 's/\.$//' | tr '[:upper:]' '[:lower:]' || true)" + fi + + if printf '%s\n' "$targets" | awk -v expected="$expected" 'tolower($0) == expected {found=1} END {exit found ? 0 : 1}'; then + pass "CNAME target for $host matches $EXPECTED_CNAME_TARGET" + return + fi + + local message="CNAME target for $host does not visibly match $EXPECTED_CNAME_TARGET" + if [[ -n "$targets" ]]; then + message="$message (observed: $(printf '%s' "$targets" | paste -sd ', ' -))" + else + message="$message (no CNAME visible; Cloudflare orange-cloud proxy can hide the target behind edge A/AAAA records)" + fi + + if [[ "$EXPECTED_CNAME_STRICT" == "1" ]]; then + fail "$message" + fi + warn "$message" +} + +check_external_network() { + if [[ "$SKIP_NETWORK" -eq 1 ]]; then + info "skipping external network checks (--skip-network)" + return + fi + + local host + host="$(node -e "const u=new URL(process.argv[1]); console.log(u.hostname)" "$BASE_URL")" + info "DNS lookup for $host" + if command -v getent >/dev/null 2>&1 && getent hosts "$host" >/dev/null 2>&1; then + pass "DNS resolves for $host" + elif command -v nslookup >/dev/null 2>&1 && nslookup "$host" >/dev/null 2>&1; then + pass "DNS resolves for $host" + elif command -v dig >/dev/null 2>&1 && dig +short "$host" | awk 'NF {found=1} END {exit found ? 0 : 1}'; then + pass "DNS resolves for $host" + else + fail "DNS does not resolve for $host" + fi + + check_expected_cname_target "$host" +} + +run_checks_for_base_url() { + BASE_URL="$(normalize_url "$1")" + + echo "== MeeChain production validation ==" + echo "Base URL: $BASE_URL" + echo "Timeout : ${TIMEOUT}s" + if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then + info "using Cloudflare Access service token headers" + fi + echo "" + + check_external_network + check_app_shell + check_health_endpoints + check_rpc_proxy + check_network_config + + echo "" + pass "production validation checklist passed for $BASE_URL" +} + +main() { + require_cmd curl + require_cmd node + + local urls=("$BASE_URL") + local extra_url + for extra_url in "${ADDITIONAL_BASE_URLS[@]}"; do + extra_url="${extra_url//[[:space:]]/}" + [[ -n "$extra_url" ]] && urls+=("$extra_url") + done + + local url + for url in "${urls[@]}"; do + run_checks_for_base_url "$url" + done + + echo "" + pass "all production validation checklists passed" +} + +main "$@" From b1ae11795ebd3633a465e60908f345da30e38035 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:54:57 +0000 Subject: [PATCH 2/6] =?UTF-8?q?=F0=9F=93=9D=20CodeRabbit=20Chat:=20Add=20u?= =?UTF-8?q?nit=20tests=20for=20PR=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/test-production-script.test.js | 824 ++++++++++++++++++++++++++++ 1 file changed, 824 insertions(+) create mode 100644 test/test-production-script.test.js diff --git a/test/test-production-script.test.js b/test/test-production-script.test.js new file mode 100644 index 0000000..2a4310a --- /dev/null +++ b/test/test-production-script.test.js @@ -0,0 +1,824 @@ +'use strict'; +/** + * Tests for scripts/test-production.sh (added in PR). + * + * Strategy: + * 1. CLI-level unit tests: exercise argument parsing, flag handling, help + * output, and URL normalisation by running the script with + * child_process.spawnSync and inspecting exit codes / stdout / stderr. + * 2. Integration tests: spin up an in-process Node.js http.createServer mock + * that returns controlled responses for every endpoint the script visits, + * then run the script pointing at localhost: with --skip-network so + * that DNS resolution is bypassed. + * + * The mock server handles: + * GET / → HTML with "MeeChain" + * GET /health → {"status":"ok","version":"1.2.3"} + * GET /api/health → {"status":"ok"} + * GET /rpc/health → {"status":"ok","chainId":13390,"mode":"proxy"} + * POST /rpc → {"result":"0x344e"} (eth_chainId) + * → {"result":"0x100"} (eth_blockNumber) + * GET /api/network → {"chainId":"0x344e","chainIdDecimal":"13390","rpcUrls":["/rpc"]} + * GET /api/web3/status → {"connected":true} + * + * All responses deliberately match the defaults checked by the script so the + * happy-path test passes without any extra env-var overrides. + */ + +const assert = require('assert'); +const { describe, it, before, after } = require('mocha'); +const http = require('http'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +// ── Paths ───────────────────────────────────────────────────────────────────── + +const SCRIPT = path.resolve(__dirname, '..', 'scripts', 'test-production.sh'); + +// ── Helper: run the script synchronously ───────────────────────────────────── + +/** + * Run the production test script synchronously with the given arguments and + * environment overrides. Returns { status, stdout, stderr }. + */ +function runScript(args = [], envOverrides = {}) { + const result = spawnSync('bash', [SCRIPT, ...args], { + encoding: 'utf8', + timeout: 15000, + env: { ...process.env, ...envOverrides }, + }); + return { + status: result.status, + stdout: result.stdout || '', + stderr: result.stderr || '', + }; +} + +// ── Helper: create a mock HTTP server ──────────────────────────────────────── + +/** + * Build the default response map. base is the full origin (http://host:port). + */ +function defaultHandlers(base) { + return { + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain DashboardMeeChain'); + }, + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', version: '1.2.3' })); + }, + 'GET /api/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + }, + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 13390, mode: 'proxy' })); + }, + 'POST /rpc': (req, res) => { + let body = ''; + req.on('data', chunk => { body += chunk; }); + req.on('end', () => { + let method = ''; + try { method = JSON.parse(body).method; } catch (_) { /* ignore */ } + res.writeHead(200, { 'content-type': 'application/json' }); + if (method === 'eth_blockNumber') { + res.end(JSON.stringify({ result: '0x100' })); + } else { + // eth_chainId and anything else + res.end(JSON.stringify({ result: '0x344e' })); + } + }); + }, + 'GET /api/network': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + chainId: '0x344e', + chainIdDecimal: '13390', + rpcUrls: [`${base}/rpc`], + })); + }, + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: true })); + }, + }; +} + +/** + * Create and start an HTTP server with a customisable handler map. + * Returns a Promise that resolves to { server, base, port }. + */ +function startMockServer(handlerOverrides = {}) { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const key = `${req.method} ${req.url}`; + const base = `http://127.0.0.1:${server.address().port}`; + const handlers = { ...defaultHandlers(base), ...handlerOverrides(base) }; + const handler = handlers[key]; + if (handler) { + handler(req, res); + } else { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end('not found'); + } + }); + + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + resolve({ server, port, base: `http://127.0.0.1:${port}` }); + }); + server.on('error', reject); + }); +} + +// ── CLI / argument-parsing tests (no network needed) ───────────────────────── + +describe('test-production.sh — CLI flags and argument parsing', () => { + it('prints usage and exits 0 with --help', () => { + const { status, stdout } = runScript(['--help']); + assert.strictEqual(status, 0, 'Expected exit code 0'); + assert.ok(stdout.includes('Usage:'), 'Expected "Usage:" in output'); + assert.ok(stdout.includes('bash scripts/test-production.sh'), 'Expected script name in usage'); + }); + + it('prints usage and exits 0 with -h', () => { + const { status, stdout } = runScript(['-h']); + assert.strictEqual(status, 0, 'Expected exit code 0'); + assert.ok(stdout.includes('Usage:'), 'Expected "Usage:" in output'); + }); + + it('exits 2 for an unknown flag', () => { + const { status, stderr } = runScript(['--no-such-flag']); + assert.strictEqual(status, 2, 'Expected exit code 2 for unknown flag'); + assert.ok(stderr.includes('Unknown option'), 'Expected "Unknown option" in stderr'); + }); + + it('exits non-zero when --also is given without a URL argument', () => { + // --also requires the next argument; omitting it should cause a bash error + const { status } = runScript(['https://localhost:1', '--also']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when --also has no argument'); + }); + + it('exits non-zero when --timeout is given without a value', () => { + const { status } = runScript(['https://localhost:1', '--timeout']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when --timeout has no value'); + }); + + it('normalises a base URL that includes a path', () => { + // The script strips the path and prints "using origin:" to stderr. + // We can observe this by pointing at a URL with a path on a non-listening + // port; the script will fail on HTTP but should still log the normalisation. + const { stderr } = runScript(['http://127.0.0.1:1/favicon.ico', '--skip-network']); + // The info message goes to stderr + assert.ok( + stderr.includes('using origin:') || stderr.includes('http://127.0.0.1:1'), + 'Expected normalisation message or origin in output', + ); + }); + + it('script file exists and is executable', () => { + const fs = require('fs'); + assert.ok(fs.existsSync(SCRIPT), `Expected ${SCRIPT} to exist`); + // Check execute bit + const mode = fs.statSync(SCRIPT).mode; + // owner-execute bit is 0o100 + assert.ok((mode & 0o111) !== 0, 'Expected script to have execute permission'); + }); + + it('defaults BASE_URL to https://rpc.meechain.live when first arg starts with --', () => { + // Pass --help as the first arg (which is a flag, not a URL), so BASE_URL + // defaults. --help exits 0 so we can inspect stdout safely. + const { status, stdout } = runScript(['--help']); + assert.strictEqual(status, 0); + assert.ok(stdout.includes('https://rpc.meechain.live'), 'Expected default URL in usage text'); + }); +}); + +// ── URL normalisation tests ─────────────────────────────────────────────────── + +describe('test-production.sh — normalize_url behaviour', () => { + it('accepts a plain origin URL without complaint', function(done) { + // Use --help which causes immediate exit; just verify the script can be + // invoked with a clean origin. + const { status } = runScript(['https://example.com', '--help']); + assert.strictEqual(status, 0); + done(); + }); + + it('strips trailing slash from URL', () => { + // We cannot easily isolate normalize_url, but we can check that the script + // does not print the "using origin:" message for a URL that already is an + // origin (no trailing slash). + const { stderr } = runScript(['https://example.com', '--help']); + // There should be no normalisation message for a clean origin + assert.ok(!stderr.includes('using origin:'), 'Should not emit normalisation message for plain origin'); + }); + + it('strips path from trycloudflare URL and emits info message', () => { + const { stderr } = runScript( + ['https://speaker-marshall-stations-antonio.trycloudflare.com/favicon.ico', '--skip-network'], + ); + assert.ok( + stderr.includes('using origin:'), + 'Expected normalisation info message when a path is included in the URL', + ); + }); + + it('fails gracefully when an invalid URL is given', () => { + const { status, stderr } = runScript(['not-a-url', '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for invalid URL'); + assert.ok( + stderr.includes('invalid base URL') || stderr.includes('not-a-url'), + 'Expected error message mentioning the invalid URL', + ); + }); +}); + +// ── PROD_TEST_ADDITIONAL_URLS env var parsing ───────────────────────────────── + +describe('test-production.sh — PROD_TEST_ADDITIONAL_URLS parsing', () => { + it('accepts an empty PROD_TEST_ADDITIONAL_URLS without error at flag level', () => { + // Just run --help; the env var parsing still runs before the arg loop. + const { status } = runScript(['--help'], { PROD_TEST_ADDITIONAL_URLS: '' }); + assert.strictEqual(status, 0); + }); + + it('parses comma-separated additional URLs from PROD_TEST_ADDITIONAL_URLS', () => { + // We cannot directly observe parsing without running full checks, but we + // can verify the script starts and processes the env var without crashing + // at parse time (before any HTTP calls) by running --help. + const { status } = runScript(['--help'], { + PROD_TEST_ADDITIONAL_URLS: 'https://a.example.com,https://b.example.com', + }); + assert.strictEqual(status, 0); + }); +}); + +// ── Integration tests with a mock HTTP server ───────────────────────────────── + +describe('test-production.sh — happy path with mock server', function() { + // These tests spawn a real shell process and make HTTP calls, so allow extra time. + this.timeout(20000); + + let server; + let base; + + before(async () => { + ({ server, base } = await startMockServer(() => ({}))); + }); + + after(done => { + server.close(done); + }); + + it('exits 0 when all endpoints return correct responses', () => { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0, `Expected exit 0; stderr: ${runScript([base, '--skip-network']).stderr}`); + assert.ok(stdout.includes('✅'), 'Expected at least one pass marker in output'); + assert.ok(stdout.includes('production validation checklist passed'), 'Expected final pass message'); + }); + + it('outputs base URL in the run header', () => { + const { stdout } = runScript([base, '--skip-network']); + assert.ok(stdout.includes('Base URL:'), 'Expected "Base URL:" in output'); + }); + + it('reports all production validation checklists passed', () => { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0); + assert.ok(stdout.includes('all production validation checklists passed')); + }); +}); + +// ── --skip-app flag ─────────────────────────────────────────────────────────── + +describe('test-production.sh — --skip-app flag', function() { + this.timeout(20000); + + let server; + let base; + + before(async () => { + // Serve a root that does NOT contain "MeeChain" to prove the app check is skipped + ({ server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('No brand text here'); + }, + }))); + }); + + after(done => server.close(done)); + + it('skips the app shell check and still passes when --skip-app is set', () => { + const { status, stdout } = runScript([base, '--skip-app', '--skip-network']); + assert.strictEqual(status, 0, 'Expected exit 0 with --skip-app even when root has no brand text'); + assert.ok(stdout.includes('skipping app shell check') || stdout.includes('production validation checklist passed')); + }); + + it('fails when root does not contain brand text without --skip-app', () => { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure when app shell text is missing'); + }); +}); + +// ── Non-200 response handling ───────────────────────────────────────────────── + +describe('test-production.sh — non-200 responses cause failure', function() { + this.timeout(20000); + + it('exits non-zero when /health returns HTTP 500', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'error' })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for /health HTTP 500'); + assert.ok( + stderr.includes('HTTP 500') || stderr.includes('expected 200'), + 'Expected HTTP status error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when / returns HTTP 404', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(404, { 'content-type': 'text/html' }); + res.end('Not Found'); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit when app shell returns 404'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /api/health returns HTTP 503', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/health': (req, res) => { + res.writeHead(503); + res.end('Service Unavailable'); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Wrong JSON field values ─────────────────────────────────────────────────── + +describe('test-production.sh — wrong JSON values cause failure', function() { + this.timeout(20000); + + it('exits non-zero when /health status is not "ok"', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'degraded', version: '1.0.0' })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0); + assert.ok( + stderr.includes('degraded') || stderr.includes('expected') || stderr.includes('ok'), + 'Expected mismatch message in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /rpc/health returns wrong chainId', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 1, mode: 'proxy' })); + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure for wrong chainId in /rpc/health'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /rpc eth_chainId returns wrong chain ID', async () => { + const { server, base } = await startMockServer(b => ({ + 'POST /rpc': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ result: '0x1' })); // wrong chain + }, + })); + try { + const { status } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure for wrong eth_chainId result'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('exits non-zero when /health has no version field', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); // missing version + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected failure when version field is absent'); + assert.ok( + stderr.includes('missing or invalid JSON field') || stderr.includes('version'), + 'Expected JSON field error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── EXPECT_UPSTREAM_CONNECTED behaviour ─────────────────────────────────────── + +describe('test-production.sh — EXPECT_UPSTREAM_CONNECTED', function() { + this.timeout(20000); + + it('warns but does not fail when connected=false and EXPECT_UPSTREAM_CONNECTED unset', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: false })); + }, + })); + try { + const { status, stdout } = runScript([base, '--skip-network']); + assert.strictEqual(status, 0, 'Should not fail when upstream is degraded without EXPECT_UPSTREAM_CONNECTED'); + assert.ok( + stdout.includes('⚠️') || stdout.includes('degraded'), + 'Expected a warning message about degraded upstream', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when connected=false and EXPECT_UPSTREAM_CONNECTED=1', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /api/web3/status': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ connected: false })); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network'], { EXPECT_UPSTREAM_CONNECTED: '1' }); + assert.notStrictEqual(status, 0, 'Expected failure when connected=false with EXPECT_UPSTREAM_CONNECTED=1'); + assert.ok( + stderr.includes('expected true') || stderr.includes('false') || stderr.includes('degraded'), + 'Expected failure message in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('passes when connected=true regardless of EXPECT_UPSTREAM_CONNECTED', async () => { + const { server, base } = await startMockServer(() => ({})); // default connected:true + try { + const { status } = runScript([base, '--skip-network'], { EXPECT_UPSTREAM_CONNECTED: '1' }); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Cloudflare Access redirect detection ───────────────────────────────────── + +describe('test-production.sh — Cloudflare Access redirect detection', function() { + this.timeout(20000); + + it('exits non-zero when a response has a Location header pointing to cloudflareaccess.com', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + // Simulate a Cloudflare Access gate redirect (302 with Location header) + res.writeHead(302, { + 'content-type': 'text/html', + 'location': 'https://example.cloudflareaccess.com/cdn-cgi/access/login?...', + }); + res.end('Redirecting...'); + }, + })); + try { + const { status, stderr } = runScript([base, '--skip-network']); + assert.notStrictEqual(status, 0, 'Expected non-zero exit for Cloudflare Access redirect'); + assert.ok( + stderr.includes('cloudflareaccess.com') || stderr.includes('Cloudflare Access'), + 'Expected Cloudflare Access mention in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── --also flag: multiple URLs ──────────────────────────────────────────────── + +describe('test-production.sh — --also flag for additional URLs', function() { + this.timeout(30000); + + it('validates multiple URLs when --also is supplied and exits 0 if both pass', async () => { + const server1Result = await startMockServer(() => ({})); + const server2Result = await startMockServer(() => ({})); + try { + const { status, stdout } = runScript( + [server1Result.base, '--also', server2Result.base, '--skip-network'], + ); + assert.strictEqual(status, 0, 'Expected exit 0 when both --also targets pass'); + // Two complete runs should yield two "checklist passed" messages + const matches = (stdout.match(/production validation checklist passed/g) || []).length; + assert.strictEqual(matches, 2, 'Expected two per-URL pass messages'); + assert.ok(stdout.includes('all production validation checklists passed')); + } finally { + await new Promise(r => server1Result.server.close(r)); + await new Promise(r => server2Result.server.close(r)); + } + }); + + it('exits non-zero when --also URL fails even if primary URL passes', async () => { + const primaryResult = await startMockServer(() => ({})); + // Secondary server returns wrong status + const secondaryResult = await startMockServer(b => ({ + 'GET /health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'down', version: '1.0.0' })); + }, + })); + try { + const { status } = runScript( + [primaryResult.base, '--also', secondaryResult.base, '--skip-network'], + ); + assert.notStrictEqual(status, 0, 'Expected failure when --also URL fails health check'); + } finally { + await new Promise(r => primaryResult.server.close(r)); + await new Promise(r => secondaryResult.server.close(r)); + } + }); +}); + +// ── PROD_TEST_ADDITIONAL_URLS integration ───────────────────────────────────── + +describe('test-production.sh — PROD_TEST_ADDITIONAL_URLS env var', function() { + this.timeout(30000); + + it('validates extra URLs from PROD_TEST_ADDITIONAL_URLS and passes when all pass', async () => { + const s1 = await startMockServer(() => ({})); + const s2 = await startMockServer(() => ({})); + const s3 = await startMockServer(() => ({})); + try { + const { status, stdout } = runScript( + [s1.base, '--skip-network'], + { PROD_TEST_ADDITIONAL_URLS: `${s2.base},${s3.base}` }, + ); + assert.strictEqual(status, 0); + const matches = (stdout.match(/production validation checklist passed/g) || []).length; + assert.strictEqual(matches, 3, 'Expected three per-URL pass messages'); + } finally { + await new Promise(r => s1.server.close(r)); + await new Promise(r => s2.server.close(r)); + await new Promise(r => s3.server.close(r)); + } + }); + + it('ignores whitespace-only entries in PROD_TEST_ADDITIONAL_URLS', async () => { + const s1 = await startMockServer(() => ({})); + try { + // Comma list with spaces only between commas — script strips whitespace + const { status } = runScript( + [s1.base, '--skip-network'], + { PROD_TEST_ADDITIONAL_URLS: ` , , ` }, + ); + // Should still pass with only the primary URL + assert.strictEqual(status, 0); + } finally { + await new Promise(r => s1.server.close(r)); + } + }); +}); + +// ── EXPECTED_CHAIN_ID overrides ─────────────────────────────────────────────── + +describe('test-production.sh — EXPECTED_CHAIN_ID_HEX and EXPECTED_CHAIN_ID_DEC overrides', function() { + this.timeout(20000); + + it('passes when custom chain IDs match server response', async () => { + // Use a non-default chain ID pair + const { server, base } = await startMockServer(b => ({ + 'GET /rpc/health': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok', chainId: 1, mode: 'proxy' })); + }, + 'POST /rpc': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ result: '0x1' })); + }, + 'GET /api/network': (req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + chainId: '0x1', + chainIdDecimal: '1', + rpcUrls: [`${b}/rpc`], + })); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { + EXPECTED_CHAIN_ID_HEX: '0x1', + EXPECTED_CHAIN_ID_DEC: '1', + }); + assert.strictEqual(status, 0, 'Expected pass with matching custom chain IDs'); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Custom EXPECTED_APP_TEXT ────────────────────────────────────────────────── + +describe('test-production.sh — EXPECTED_APP_TEXT env var', function() { + this.timeout(20000); + + it('passes when custom app text is present in root response', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('CustomBrand Dashboard'); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { EXPECTED_APP_TEXT: 'CustomBrand' }); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when custom app text is not present in root response', async () => { + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain Dashboard'); + }, + })); + try { + const { status } = runScript([base, '--skip-network'], { EXPECTED_APP_TEXT: 'NonexistentBrand' }); + assert.notStrictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── CF Access headers are forwarded ────────────────────────────────────────── + +describe('test-production.sh — Cloudflare Access service token headers', function() { + this.timeout(20000); + + it('forwards CF-Access headers when credentials are set', async () => { + let receivedId = ''; + let receivedSecret = ''; + + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + receivedId = req.headers['cf-access-client-id'] || ''; + receivedSecret = req.headers['cf-access-client-secret'] || ''; + res.writeHead(200, { 'content-type': 'text/html' }); + res.end('MeeChain'); + }, + })); + try { + runScript([base, '--skip-network'], { + CF_ACCESS_CLIENT_ID: 'test-client-id', + CF_ACCESS_CLIENT_SECRET: 'test-client-secret', + }); + // Give the request a moment; spawnSync is synchronous so values are set + assert.strictEqual(receivedId, 'test-client-id', 'Expected CF-Access-Client-Id header'); + assert.strictEqual(receivedSecret, 'test-client-secret', 'Expected CF-Access-Client-Secret header'); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('logs "using Cloudflare Access service token headers" when credentials set', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { stdout } = runScript([base, '--skip-network'], { + CF_ACCESS_CLIENT_ID: 'id123', + CF_ACCESS_CLIENT_SECRET: 'secret456', + }); + assert.ok( + stdout.includes('Cloudflare Access service token'), + 'Expected Cloudflare Access token log message', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── --timeout flag ──────────────────────────────────────────────────────────── + +describe('test-production.sh — --timeout flag', function() { + this.timeout(20000); + + it('accepts a custom --timeout value and passes it through', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { status } = runScript([base, '--skip-network', '--timeout', '5']); + assert.strictEqual(status, 0, 'Expected exit 0 with custom --timeout value'); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── normalize_dns_name logic (tested via check_expected_cname_target) ───────── + +describe('test-production.sh — normalize_dns_name / EXPECTED_CNAME_TARGET', function() { + this.timeout(20000); + + it('issues a warning (not failure) when CNAME is not visible and EXPECTED_CNAME_STRICT unset', async () => { + // Use localhost which has no CNAME record; the script should warn + const { server, base } = await startMockServer(() => ({})); + // Enable network so CNAME lookup runs, but use localhost (no CNAME) + try { + const { status, stdout } = runScript([base], { + EXPECTED_CNAME_TARGET: 'some-tunnel.cfargotunnel.com', + EXPECTED_CNAME_STRICT: '0', + }); + // Should still exit 0 (warn, not fail) + assert.strictEqual(status, 0, 'Expected exit 0 (warning) when CNAME not visible without strict mode'); + assert.ok( + stdout.includes('⚠️') || stdout.includes('no CNAME visible'), + 'Expected a warning about CNAME not being visible', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); + + it('fails when CNAME is not visible and EXPECTED_CNAME_STRICT=1', async () => { + const { server, base } = await startMockServer(() => ({})); + try { + const { status, stderr } = runScript([base], { + EXPECTED_CNAME_TARGET: 'some-tunnel.cfargotunnel.com', + EXPECTED_CNAME_STRICT: '1', + }); + assert.notStrictEqual(status, 0, 'Expected failure in strict CNAME mode when CNAME is not visible'); + assert.ok( + stderr.includes('CNAME') || stderr.includes('does not visibly match'), + 'Expected CNAME mismatch error in stderr', + ); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); + +// ── Regression: script passes with --skip-network --skip-app ───────────────── + +describe('test-production.sh — combined skip flags', function() { + this.timeout(20000); + + it('exits 0 with --skip-network --skip-app when health/rpc endpoints pass', async () => { + // Root is intentionally blank; with --skip-app and --skip-network neither + // DNS nor the app shell are checked. + const { server, base } = await startMockServer(b => ({ + 'GET /': (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('no brand text'); + }, + })); + try { + const { status } = runScript([base, '--skip-network', '--skip-app']); + assert.strictEqual(status, 0); + } finally { + await new Promise(r => server.close(r)); + } + }); +}); From 416358980d0c5ac3321f61a55a2489e08c38b544 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:02:54 +0000 Subject: [PATCH 3/6] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/check-docker-container-status`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @MEECHAIN1. * https://github.com/MEECHAIN1/MeeChain-Connect/pull/83#issuecomment-4387356415 The following files were modified: * `scripts/test-production.sh` --- scripts/test-production.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) mode change 100755 => 100644 scripts/test-production.sh diff --git a/scripts/test-production.sh b/scripts/test-production.sh old mode 100755 new mode 100644 index 18bae57..945f182 --- a/scripts/test-production.sh +++ b/scripts/test-production.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Production post-deploy validation for MeeChain Connect. +# usage prints the help message describing the script's command-line arguments, examples, and environment variables. usage() { cat <<'USAGE' @@ -91,11 +91,16 @@ HEADERS="$TMP_DIR/headers" BODY="$TMP_DIR/body" trap 'rm -rf "$TMP_DIR"' EXIT +# pass prints the provided message prefixed with a checkmark (✅) to stdout. pass() { echo "✅ $*"; } +# fail prints an error message to stderr and exits the script with code 1. fail() { echo "❌ $*" >&2; exit 1; } +# warn prints a warning message to stdout with a warning emoji prefix. warn() { echo "⚠️ $*"; } +# info พิมพ์ข้อความข้อมูลไปยัง stdout พร้อมคำนำหน้า ℹ️ info() { echo "ℹ️ $*"; } +# require_cmd ตรวจสอบว่าคำสั่งมีอยู่ใน PATH หากไม่พบจะหยุดสคริปต์ด้วยข้อผิดพลาด require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then @@ -103,6 +108,7 @@ require_cmd() { fi } +# normalize_url แปลง URL ให้เป็น origin (แบบแผน โฮสต์ และพอร์ต) และส่งออกไปยัง stdout หรือล้มเหลวหากกำหนด URL ไม่ถูกต้อง normalize_url() { local raw="$1" local original="${raw%/}" @@ -127,10 +133,12 @@ if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then ACCESS_HEADERS+=(-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}") fi +# status_code extracts and prints the HTTP status code from the response headers file. status_code() { awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" } +# assert_no_access_redirect fails if a Cloudflare Access redirect is detected in the HTTP response headers. assert_no_access_redirect() { local location location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" @@ -139,6 +147,7 @@ assert_no_access_redirect() { fi } +# curl_get performs an HTTP GET request to the specified URL and saves the response headers and body for subsequent validation. curl_get() { local url="$1" if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" "${ACCESS_HEADERS[@]}" "$url"; then @@ -146,6 +155,7 @@ curl_get() { fi } +# curl_post_json posts JSON to the specified URL and saves the response. curl_post_json() { local url="$1" local payload="$2" @@ -158,6 +168,7 @@ curl_post_json() { fi } +# assert_http_200 validates that the most recent HTTP response has status code 200. assert_http_200() { local label="$1" local code @@ -173,11 +184,13 @@ assert_http_200() { pass "$label HTTP 200" } +# json_field อ่านข้อมูล JSON จากการตอบสนอง ประเมินนิพจน์ที่ให้มา และแสดงผลลัพธ์ หรือออกจากโปรแกรมด้วยรหัส 3 หากค่าไม่มีหรือเป็น null json_field() { local expression="$1" node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" } +# assert_json_field_equals ตรวจสอบว่าเขตข้อมูล JSON ของการตอบสนองตรงกับค่าที่คาดหวัง assert_json_field_equals() { local label="$1" local expression="$2" @@ -192,6 +205,7 @@ assert_json_field_equals() { pass "$label = $actual" } +# assert_json_field_present ตรวจสอบว่าช่องข้อมูล JSON ที่ระบุโดยนิพจน์นั้นมีอยู่และถูกต้อง assert_json_field_present() { local label="$1" local expression="$2" @@ -199,6 +213,7 @@ assert_json_field_present() { pass "$label present" } +# assert_json_field_truthy_or_warn confirms a JSON field equals true, failing in strict mode (EXPECT_UPSTREAM_CONNECTED=1) or warning in lenient mode with degraded behavior noted. assert_json_field_truthy_or_warn() { local label="$1" local expression="$2" @@ -215,6 +230,7 @@ assert_json_field_truthy_or_warn() { fi } +# assert_body_contains ตรวจสอบว่าเนื้อหาการตอบสนองมีข้อความที่กำหนด assert_body_contains() { local label="$1" local expected="$2" @@ -227,6 +243,7 @@ assert_body_contains() { pass "$label contains '$expected'" } +# check_get_json ส่งคำขอ GET ไปยังจุดปลายทาง API และตรวจสอบว่าการตอบสนอง HTTP มีสถานะ 200 check_get_json() { local path="$1" local label="$2" @@ -236,6 +253,7 @@ check_get_json() { } +# check_app_shell ตรวจสอบว่าเปลือกแอปพลิเคชันมีข้อความที่คาดหวัง check_app_shell() { if [[ "$SKIP_APP" -eq 1 ]]; then info "skipping app shell check (--skip-app)" @@ -246,6 +264,7 @@ check_app_shell() { assert_body_contains "app shell" "$EXPECTED_APP_TEXT" } +# check_health_endpoints validates the health status of root, API, and RPC endpoints and confirms correct chain configuration. check_health_endpoints() { check_get_json "/health" "root health" assert_json_field_equals "root health status" "data.status" "ok" @@ -260,6 +279,7 @@ check_health_endpoints() { assert_json_field_present "RPC health mode" "data.mode" } +# check_rpc_proxy ตรวจสอบจุดปลายทาง RPC proxy โดยตรวจสอบการตอบสนองรหัสเชนและหมายเลขบล็อก check_rpc_proxy() { info "POST ${BASE_URL}/rpc eth_chainId" curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' @@ -272,6 +292,7 @@ check_rpc_proxy() { assert_json_field_present "RPC eth_blockNumber result" "data.result" } +# check_network_config ตรวจสอบการกำหนดค่าเครือข่ายและสถานะการเชื่อมต่อ web3 check_network_config() { check_get_json "/api/network" "network config" assert_json_field_equals "network chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_HEX" @@ -283,12 +304,14 @@ check_network_config() { } +# normalize_dns_name removes trailing dots from a DNS name and converts it to lowercase. normalize_dns_name() { local name="$1" name="${name%.}" printf '%s\n' "${name,,}" } +# check_expected_cname_target validates that a host's CNAME record matches the expected target, failing or warning based on the EXPECTED_CNAME_STRICT setting. check_expected_cname_target() { local host="$1" [[ -n "$EXPECTED_CNAME_TARGET" ]] || return @@ -320,6 +343,7 @@ check_expected_cname_target() { warn "$message" } +# check_external_network validates DNS resolution for the base URL's hostname and checks its CNAME target against expected configuration. check_external_network() { if [[ "$SKIP_NETWORK" -eq 1 ]]; then info "skipping external network checks (--skip-network)" @@ -342,6 +366,7 @@ check_external_network() { check_expected_cname_target "$host" } +# run_checks_for_base_url orchestrates production post-deploy validation checks for a MeeChain Connect HTTP endpoint. run_checks_for_base_url() { BASE_URL="$(normalize_url "$1")" @@ -363,6 +388,7 @@ run_checks_for_base_url() { pass "production validation checklist passed for $BASE_URL" } +# main orchestrates production post-deployment validation for the primary and additional configured base URLs. main() { require_cmd curl require_cmd node From 45c3e69cd7939f2cf7d1fba0aa7bd7bcccaf3092 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:03:45 +0000 Subject: [PATCH 4/6] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/check-docker-container-status`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @coderabbitai[bot]. The following files were modified: * `scripts/test-production.sh` These files were ignored: * `test/test-production-script.test.js` These file types are not supported: * `README.md` --- scripts/test-production.sh | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) mode change 100755 => 100644 scripts/test-production.sh diff --git a/scripts/test-production.sh b/scripts/test-production.sh old mode 100755 new mode 100644 index 18bae57..1db4f5a --- a/scripts/test-production.sh +++ b/scripts/test-production.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Production post-deploy validation for MeeChain Connect. +# usage() แสดงข้อความช่วยเหลือที่อธิบายวิธีการใช้งานสคริปต์ อาร์กิวเมนต์ ตัวอย่าง และตัวแปรสภาพแวดล้อม usage() { cat <<'USAGE' @@ -91,11 +91,16 @@ HEADERS="$TMP_DIR/headers" BODY="$TMP_DIR/body" trap 'rm -rf "$TMP_DIR"' EXIT +# pass prints a success marker with the provided message. pass() { echo "✅ $*"; } +# fail แสดงข้อความข้อผิดพลาดพร้อมเครื่องหมายล้มเหลวไปยัง stderr และหยุดการทำงานของสคริปต์ fail() { echo "❌ $*" >&2; exit 1; } +# warn prints a warning message prefixed with a warning emoji. warn() { echo "⚠️ $*"; } +# info prints an informational message prefixed with an info emoji. info() { echo "ℹ️ $*"; } +# require_cmd checks that a required command exists in PATH. require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then @@ -103,6 +108,7 @@ require_cmd() { fi } +# normalize_url extracts and echoes the origin (scheme, host, and optional port) from a URL string. normalize_url() { local raw="$1" local original="${raw%/}" @@ -127,10 +133,12 @@ if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then ACCESS_HEADERS+=(-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}") fi +# status_code ดึงรหัสสถานะ HTTP จากไฟล์ headers และพิมพ์ออก status_code() { awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" } +# assert_no_access_redirect verifies the HTTP response was not blocked by Cloudflare Access. assert_no_access_redirect() { local location location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" @@ -139,6 +147,7 @@ assert_no_access_redirect() { fi } +# curl_get ส่งคำขอ GET ไปยัง URL ที่ระบุและบันทึกส่วนหัวและเนื้อหาของการตอบสนองลงในไฟล์ curl_get() { local url="$1" if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" "${ACCESS_HEADERS[@]}" "$url"; then @@ -146,6 +155,7 @@ curl_get() { fi } +# curl_post_json ส่งคำขอ POST พร้อมข้อมูล JSON ไปยัง URL ที่ระบุ curl_post_json() { local url="$1" local payload="$2" @@ -158,6 +168,7 @@ curl_post_json() { fi } +# assert_http_200 verifies that the HTTP response has status 200 and is not a Cloudflare Access redirect. assert_http_200() { local label="$1" local code @@ -173,11 +184,13 @@ assert_http_200() { pass "$label HTTP 200" } +# json_field evaluates a JavaScript expression against the parsed body JSON and prints the result, exiting with status 3 if the value is undefined or null. json_field() { local expression="$1" node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" } +# assert_json_field_equals asserts that a JSON field from the response equals an expected value. assert_json_field_equals() { local label="$1" local expression="$2" @@ -192,6 +205,7 @@ assert_json_field_equals() { pass "$label = $actual" } +# assert_json_field_present ตรวจสอบว่าฟิลด์ JSON ที่ระบุมีอยู่ในข้อมูล JSON และมีค่าที่กำหนด assert_json_field_present() { local label="$1" local expression="$2" @@ -199,6 +213,7 @@ assert_json_field_present() { pass "$label present" } +# assert_json_field_truthy_or_warn ตรวจสอบว่าฟิลด์ JSON เป็นจริง โดยล้มเหลวเมื่อ EXPECT_UPSTREAM_CONNECTED ถูกตั้งค่าเป็น 1 หรือแสดงคำเตือนเป็นอย่างอื่น assert_json_field_truthy_or_warn() { local label="$1" local expression="$2" @@ -215,6 +230,7 @@ assert_json_field_truthy_or_warn() { fi } +# assert_body_contains ยืนยันว่าเนื้อหาการตอบสนอง HTTP มีข้อความที่คาดหวัง assert_body_contains() { local label="$1" local expected="$2" @@ -227,6 +243,7 @@ assert_body_contains() { pass "$label contains '$expected'" } +# check_get_json validates that a GET request to the specified endpoint path returns HTTP 200 status. check_get_json() { local path="$1" local label="$2" @@ -236,6 +253,7 @@ check_get_json() { } +# check_app_shell ตรวจสอบว่าเส้นทางรูตประกอบด้วยข้อความแอปพลิเคชันที่คาดหวัง check_app_shell() { if [[ "$SKIP_APP" -eq 1 ]]; then info "skipping app shell check (--skip-app)" @@ -246,6 +264,7 @@ check_app_shell() { assert_body_contains "app shell" "$EXPECTED_APP_TEXT" } +# check_health_endpoints ตรวจสอบสถานะความสุขและข้อมูลเมตาดาต้าของจุดสิ้นสุด health ในแอปพลิเคชัน API และ RPC check_health_endpoints() { check_get_json "/health" "root health" assert_json_field_equals "root health status" "data.status" "ok" @@ -260,6 +279,7 @@ check_health_endpoints() { assert_json_field_present "RPC health mode" "data.mode" } +# check_rpc_proxy validates JSON-RPC proxy responses for eth_chainId and eth_blockNumber calls. check_rpc_proxy() { info "POST ${BASE_URL}/rpc eth_chainId" curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' @@ -272,6 +292,7 @@ check_rpc_proxy() { assert_json_field_present "RPC eth_blockNumber result" "data.result" } +# check_network_config ตรวจสอบการกำหนดค่าเครือข่ายและสถานะการเชื่อมต่อ web3 check_network_config() { check_get_json "/api/network" "network config" assert_json_field_equals "network chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_HEX" @@ -283,12 +304,14 @@ check_network_config() { } +# normalize_dns_name normalizes a DNS name by removing a trailing dot and converting it to lowercase. normalize_dns_name() { local name="$1" name="${name%.}" printf '%s\n' "${name,,}" } +# check_expected_cname_target ตรวจสอบว่า CNAME record ของโฮสต์ตรงกับค่าเป้าหมายที่คาดหวัง check_expected_cname_target() { local host="$1" [[ -n "$EXPECTED_CNAME_TARGET" ]] || return @@ -320,6 +343,7 @@ check_expected_cname_target() { warn "$message" } +# check_external_network ตรวจสอบการแก้ไขชื่อ DNS สำหรับโฮสต์ของ URL ฐานและตรวจสอบการกำหนดค่า CNAME check_external_network() { if [[ "$SKIP_NETWORK" -eq 1 ]]; then info "skipping external network checks (--skip-network)" @@ -342,6 +366,7 @@ check_external_network() { check_expected_cname_target "$host" } +# run_checks_for_base_url ดำเนินการตรวจสอบ production validation สำหรับ base URL ที่ให้มา ทดสอบเครือข่าย สถานะแอปพลิเคชัน health endpoints RPC proxy และการตั้งค่าเครือข่าย run_checks_for_base_url() { BASE_URL="$(normalize_url "$1")" @@ -363,6 +388,7 @@ run_checks_for_base_url() { pass "production validation checklist passed for $BASE_URL" } +# main runs production validation checks across all configured base URLs, reporting success if all pass. main() { require_cmd curl require_cmd node From d3e7dc7eee0ec548226703ae89081ce46b022250 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:05:20 +0000 Subject: [PATCH 5/6] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/check-docker-container-status`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @coderabbitai[bot]. The following files were modified: * `scripts/test-production.sh` These files were ignored: * `test/test-production-script.test.js` These file types are not supported: * `README.md` --- scripts/test-production.sh | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/scripts/test-production.sh b/scripts/test-production.sh index 1db4f5a..9b2a895 100644 --- a/scripts/test-production.sh +++ b/scripts/test-production.sh @@ -100,7 +100,7 @@ warn() { echo "⚠️ $*"; } # info prints an informational message prefixed with an info emoji. info() { echo "ℹ️ $*"; } -# require_cmd checks that a required command exists in PATH. +# require_cmd verifies that a required command exists in PATH and exits with an error if not found. require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then @@ -138,7 +138,7 @@ status_code() { awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" } -# assert_no_access_redirect verifies the HTTP response was not blocked by Cloudflare Access. +# assert_no_access_redirect fails if the HTTP response contains a Cloudflare Access redirect. assert_no_access_redirect() { local location location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" @@ -155,7 +155,7 @@ curl_get() { fi } -# curl_post_json ส่งคำขอ POST พร้อมข้อมูล JSON ไปยัง URL ที่ระบุ +# curl_post_json ส่งคำขอ POST พร้อมข้อมูล JSON ไปยัง URL ที่ระบุ และบันทึกส่วนหัวการตอบกลับไปยัง HEADERS และเนื้อหาไปยัง BODY curl_post_json() { local url="$1" local payload="$2" @@ -190,7 +190,7 @@ json_field() { node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" } -# assert_json_field_equals asserts that a JSON field from the response equals an expected value. +# assert_json_field_equals verifies that a JSON field extracted from the response matches an expected value. assert_json_field_equals() { local label="$1" local expression="$2" @@ -205,7 +205,7 @@ assert_json_field_equals() { pass "$label = $actual" } -# assert_json_field_present ตรวจสอบว่าฟิลด์ JSON ที่ระบุมีอยู่ในข้อมูล JSON และมีค่าที่กำหนด +# assert_json_field_present ยืนยันว่าฟิลด์ JSON ที่ระบุมีอยู่และมีค่า assert_json_field_present() { local label="$1" local expression="$2" @@ -213,7 +213,7 @@ assert_json_field_present() { pass "$label present" } -# assert_json_field_truthy_or_warn ตรวจสอบว่าฟิลด์ JSON เป็นจริง โดยล้มเหลวเมื่อ EXPECT_UPSTREAM_CONNECTED ถูกตั้งค่าเป็น 1 หรือแสดงคำเตือนเป็นอย่างอื่น +# assert_json_field_truthy_or_warn validates a JSON field is true, with behavior determined by EXPECT_UPSTREAM_CONNECTED. assert_json_field_truthy_or_warn() { local label="$1" local expression="$2" @@ -230,7 +230,7 @@ assert_json_field_truthy_or_warn() { fi } -# assert_body_contains ยืนยันว่าเนื้อหาการตอบสนอง HTTP มีข้อความที่คาดหวัง +# assert_body_contains ยืนยันว่าเนื้อหาการตอบสนอง HTTP มีข้อความที่คาดหวัง โดยพิมพ์ข้อมูลตัวอย่างและหยุดการทำงานหากข้อความไม่พบ assert_body_contains() { local label="$1" local expected="$2" @@ -304,7 +304,7 @@ check_network_config() { } -# normalize_dns_name normalizes a DNS name by removing a trailing dot and converting it to lowercase. +# normalize_dns_name ปกติเป็นชื่อ DNS โดยลบจุดท้ายและแปลงเป็นตัวพิมพ์เล็ก normalize_dns_name() { local name="$1" name="${name%.}" @@ -366,7 +366,7 @@ check_external_network() { check_expected_cname_target "$host" } -# run_checks_for_base_url ดำเนินการตรวจสอบ production validation สำหรับ base URL ที่ให้มา ทดสอบเครือข่าย สถานะแอปพลิเคชัน health endpoints RPC proxy และการตั้งค่าเครือข่าย +# run_checks_for_base_url ดำเนินการตรวจสอบ production validation สำหรับ base URL ที่ระบุ run_checks_for_base_url() { BASE_URL="$(normalize_url "$1")" From c012185df4b6f8428a04caab0d0e6b794db5539d Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Thu, 18 Jun 2026 12:08:41 +0000 Subject: [PATCH 6/6] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/check-docker-container-status`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @MEECHAIN1. The following files were modified: * `scripts/test-production.sh` These file types are not supported: * `README.md` --- scripts/test-production.sh | 54 +++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/scripts/test-production.sh b/scripts/test-production.sh index 9b2a895..6e9d69f 100644 --- a/scripts/test-production.sh +++ b/scripts/test-production.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# usage() แสดงข้อความช่วยเหลือที่อธิบายวิธีการใช้งานสคริปต์ อาร์กิวเมนต์ ตัวอย่าง และตัวแปรสภาพแวดล้อม +# usage() แสดงข้อความช่วยเหลือการใช้งาน ตัวอย่างคำสั่ง และตัวแปรสภาพแวดล้อมสำหรับการตรวจสอบการทำงานในสภาพแวดล้อมการผลิต usage() { cat <<'USAGE' @@ -91,16 +91,16 @@ HEADERS="$TMP_DIR/headers" BODY="$TMP_DIR/body" trap 'rm -rf "$TMP_DIR"' EXIT -# pass prints a success marker with the provided message. +# pass prints a success message to stdout. pass() { echo "✅ $*"; } -# fail แสดงข้อความข้อผิดพลาดพร้อมเครื่องหมายล้มเหลวไปยัง stderr และหยุดการทำงานของสคริปต์ +# fail พิมพ์ข้อความข้อผิดพลาดไปยังเอาต์พุตข้อผิดพลาดและออกจากสคริปต์ fail() { echo "❌ $*" >&2; exit 1; } -# warn prints a warning message prefixed with a warning emoji. +# warn prints a warning message with a warning emoji prefix to stdout. warn() { echo "⚠️ $*"; } -# info prints an informational message prefixed with an info emoji. +# info แสดงข้อความข้อมูล info() { echo "ℹ️ $*"; } -# require_cmd verifies that a required command exists in PATH and exits with an error if not found. +# require_cmd ตรวจสอบว่าคำสั่งที่ระบุมีอยู่ในระบบ หากไม่พบจะออกจากโปรแกรม require_cmd() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then @@ -108,7 +108,7 @@ require_cmd() { fi } -# normalize_url extracts and echoes the origin (scheme, host, and optional port) from a URL string. +# normalize_url ทำให้ URL เป็นมาตรฐานโดยแยก origin (scheme + host + port) ออกจากอักษร URL โดยลบเส้นทาง คิวรี และชิ้นส่วน แล้วพิมพ์ origin ไปยัง stdout หากรูปแบบ URL ไม่ถูกต้อง ให้เรียก fail normalize_url() { local raw="$1" local original="${raw%/}" @@ -133,12 +133,12 @@ if [[ -n "$CF_ACCESS_CLIENT_ID" && -n "$CF_ACCESS_CLIENT_SECRET" ]]; then ACCESS_HEADERS+=(-H "CF-Access-Client-Secret: ${CF_ACCESS_CLIENT_SECRET}") fi -# status_code ดึงรหัสสถานะ HTTP จากไฟล์ headers และพิมพ์ออก +# status_code extracts the HTTP status code from the saved headers file and echoes it to stdout. status_code() { awk 'toupper($1) ~ /^HTTP/ {code=$2} END{print code}' "$HEADERS" } -# assert_no_access_redirect fails if the HTTP response contains a Cloudflare Access redirect. +# assert_no_access_redirect fails if the HTTP response headers contain a Cloudflare Access redirect. assert_no_access_redirect() { local location location="$(awk 'tolower($1)=="location:" {print $2}' "$HEADERS" | tr -d '\r' || true)" @@ -147,7 +147,7 @@ assert_no_access_redirect() { fi } -# curl_get ส่งคำขอ GET ไปยัง URL ที่ระบุและบันทึกส่วนหัวและเนื้อหาของการตอบสนองลงในไฟล์ +# curl_get ส่งคำขอ GET ไปยัง URL และบันทึก headers และ body ของ response ลงในไฟล์ชั่วคราว curl_get() { local url="$1" if ! curl "${CURL_ARGS[@]}" -D "$HEADERS" -o "$BODY" "${ACCESS_HEADERS[@]}" "$url"; then @@ -155,7 +155,7 @@ curl_get() { fi } -# curl_post_json ส่งคำขอ POST พร้อมข้อมูล JSON ไปยัง URL ที่ระบุ และบันทึกส่วนหัวการตอบกลับไปยัง HEADERS และเนื้อหาไปยัง BODY +# curl_post_json performs an HTTP POST request with JSON content to a specified URL. curl_post_json() { local url="$1" local payload="$2" @@ -168,7 +168,7 @@ curl_post_json() { fi } -# assert_http_200 verifies that the HTTP response has status 200 and is not a Cloudflare Access redirect. +# assert_http_200 validates that the HTTP response has status code 200 and passes Cloudflare Access verification. assert_http_200() { local label="$1" local code @@ -184,13 +184,13 @@ assert_http_200() { pass "$label HTTP 200" } -# json_field evaluates a JavaScript expression against the parsed body JSON and prints the result, exiting with status 3 if the value is undefined or null. +# json_field evaluates a JavaScript expression against the parsed JSON in the response body and prints the resolved value, or exits with code 3 if the value is undefined or null. json_field() { local expression="$1" node -e "const fs=require('fs'); const data=JSON.parse(fs.readFileSync(process.argv[1], 'utf8')); const value=($expression); if (value === undefined || value === null) process.exit(3); console.log(typeof value === 'object' ? JSON.stringify(value) : value);" "$BODY" } -# assert_json_field_equals verifies that a JSON field extracted from the response matches an expected value. +# assert_json_field_equals ตรวจสอบว่า JSON field มีค่าตรงกับค่าที่คาดไว้ assert_json_field_equals() { local label="$1" local expression="$2" @@ -205,7 +205,7 @@ assert_json_field_equals() { pass "$label = $actual" } -# assert_json_field_present ยืนยันว่าฟิลด์ JSON ที่ระบุมีอยู่และมีค่า +# assert_json_field_present ตรวจสอบว่านิพจน์ JSON ที่ระบุนั้นมีอยู่และสามารถเข้าถึงได้ assert_json_field_present() { local label="$1" local expression="$2" @@ -213,7 +213,7 @@ assert_json_field_present() { pass "$label present" } -# assert_json_field_truthy_or_warn validates a JSON field is true, with behavior determined by EXPECT_UPSTREAM_CONNECTED. +# assert_json_field_truthy_or_warn validates that a JSON field value is the string 'true', failing if upstream connectivity is required, otherwise warning of degraded state. assert_json_field_truthy_or_warn() { local label="$1" local expression="$2" @@ -230,7 +230,7 @@ assert_json_field_truthy_or_warn() { fi } -# assert_body_contains ยืนยันว่าเนื้อหาการตอบสนอง HTTP มีข้อความที่คาดหวัง โดยพิมพ์ข้อมูลตัวอย่างและหยุดการทำงานหากข้อความไม่พบ +# assert_body_contains ตรวจสอบว่าเนื้อหาการตอบสนองประกอบด้วยข้อความที่คาดหวัง และพิมพ์ข้อมูลการวินิจฉัยหากล้มเหลว assert_body_contains() { local label="$1" local expected="$2" @@ -243,7 +243,7 @@ assert_body_contains() { pass "$label contains '$expected'" } -# check_get_json validates that a GET request to the specified endpoint path returns HTTP 200 status. +# check_get_json ส่งคำขอ GET ไปยังเส้นทางและตรวจสอบว่าได้รับการตอบสนอง HTTP 200 check_get_json() { local path="$1" local label="$2" @@ -253,7 +253,7 @@ check_get_json() { } -# check_app_shell ตรวจสอบว่าเส้นทางรูตประกอบด้วยข้อความแอปพลิเคชันที่คาดหวัง +# check_app_shell verifies that the application shell endpoint responds successfully and contains the expected application text. check_app_shell() { if [[ "$SKIP_APP" -eq 1 ]]; then info "skipping app shell check (--skip-app)" @@ -264,7 +264,7 @@ check_app_shell() { assert_body_contains "app shell" "$EXPECTED_APP_TEXT" } -# check_health_endpoints ตรวจสอบสถานะความสุขและข้อมูลเมตาดาต้าของจุดสิ้นสุด health ในแอปพลิเคชัน API และ RPC +# check_health_endpoints ตรวจสอบเอนดพอยต์สุขภาพและยืนยันว่าสถานะทั้งหมดคืนค่า ok พร้อมฟิลด์ที่คาดหวัง check_health_endpoints() { check_get_json "/health" "root health" assert_json_field_equals "root health status" "data.status" "ok" @@ -279,7 +279,7 @@ check_health_endpoints() { assert_json_field_present "RPC health mode" "data.mode" } -# check_rpc_proxy validates JSON-RPC proxy responses for eth_chainId and eth_blockNumber calls. +# check_rpc_proxy ตรวจสอบ RPC proxy endpoint ด้วยการเรียก JSON-RPC eth_chainId และ eth_blockNumber พร้อมยืนยันว่า chain ID ตรงกับที่คาดหวังและ block number มีอยู่ check_rpc_proxy() { info "POST ${BASE_URL}/rpc eth_chainId" curl_post_json "${BASE_URL}/rpc" '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' @@ -292,7 +292,7 @@ check_rpc_proxy() { assert_json_field_present "RPC eth_blockNumber result" "data.result" } -# check_network_config ตรวจสอบการกำหนดค่าเครือข่ายและสถานะการเชื่อมต่อ web3 +# check_network_config validates network configuration endpoints for correct chain IDs, RPC URLs, and web3 connectivity. check_network_config() { check_get_json "/api/network" "network config" assert_json_field_equals "network chainId" "String(data.chainId)" "$EXPECTED_CHAIN_ID_HEX" @@ -304,14 +304,14 @@ check_network_config() { } -# normalize_dns_name ปกติเป็นชื่อ DNS โดยลบจุดท้ายและแปลงเป็นตัวพิมพ์เล็ก +# normalize_dns_name removes the trailing dot from a DNS name and converts it to lowercase, echoing the normalized result. normalize_dns_name() { local name="$1" name="${name%.}" printf '%s\n' "${name,,}" } -# check_expected_cname_target ตรวจสอบว่า CNAME record ของโฮสต์ตรงกับค่าเป้าหมายที่คาดหวัง +# check_expected_cname_target ตรวจสอบว่า CNAME target ของโฮสต์ตรงกับค่าที่ระบุใน EXPECTED_CNAME_TARGET โดยส่งผลสำเร็จหรือเตือนตามระดับความเข้มงวดของ EXPECTED_CNAME_STRICT check_expected_cname_target() { local host="$1" [[ -n "$EXPECTED_CNAME_TARGET" ]] || return @@ -343,7 +343,7 @@ check_expected_cname_target() { warn "$message" } -# check_external_network ตรวจสอบการแก้ไขชื่อ DNS สำหรับโฮสต์ของ URL ฐานและตรวจสอบการกำหนดค่า CNAME +# check_external_network validates DNS resolution for the hostname in the base URL and checks expected CNAME targets. check_external_network() { if [[ "$SKIP_NETWORK" -eq 1 ]]; then info "skipping external network checks (--skip-network)" @@ -366,7 +366,7 @@ check_external_network() { check_expected_cname_target "$host" } -# run_checks_for_base_url ดำเนินการตรวจสอบ production validation สำหรับ base URL ที่ระบุ +# run_checks_for_base_url ดำเนินการตรวจสอบการผลิตสำหรับ URL ฐานที่กำหนด run_checks_for_base_url() { BASE_URL="$(normalize_url "$1")" @@ -388,7 +388,7 @@ run_checks_for_base_url() { pass "production validation checklist passed for $BASE_URL" } -# main runs production validation checks across all configured base URLs, reporting success if all pass. +# main validates the production deployment by running health and RPC checks against configured base URLs. main() { require_cmd curl require_cmd node