Files
meshcore-analyzer/tools/check-parity.sh
Kpa-clawbot 47531e5487 Add golden fixture parity test suite — Go must match Node shapes
- Capture Node.js API response shapes from prod server as golden fixtures
- Store normalized shape schema in cmd/server/testdata/golden/shapes.json
  covering 16 endpoints: stats, nodes, packets (raw + grouped), observers,
  channels, channel_messages, analytics (rf, topology, hash-sizes, distance,
  subpaths), bulk-health, health, perf, and node detail
- Add parity_test.go with recursive shape validator:
  - TestParityShapes: validates Go response keys/types match Node golden
  - TestParityNodeDetail: validates node detail response shape
  - TestParityArraysNotNull: catches nil slices marshaled as null
  - TestParityHealthEngine: verifies Go identifies itself as engine=go
  - TestValidateShapeFunction: unit tests for the validator itself
- Add tools/check-parity.sh for live Node vs Go comparison on VM
- Shape spec handles dynamic-key objects (perObserverReach, perf.endpoints)
- Nullable fields properly marked (observer lat/lon, snr/rssi, hop names)

Current mismatches found (genuine Go bugs):
- /api/perf: packetStore missing 8 fields, sqlite missing 2 fields
- /api/nodes/{pubkey}: missing hash_sizes_seen, observations, _parsedPath,
  _parsedDecoded in node detail response

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 11:37:56 -07:00

180 lines
5.5 KiB
Bash

#!/usr/bin/env bash
# tools/check-parity.sh — Compare Node.js and Go API response shapes
#
# Usage:
# bash tools/check-parity.sh # run on VM (default ports)
# bash tools/check-parity.sh NODE_PORT GO_PORT # custom ports
# ssh deploy@<VM_HOST> 'bash ~/meshcore-analyzer/tools/check-parity.sh'
#
# Compares response SHAPES (keys + types), not values.
# Requires: curl, python3
set -euo pipefail
NODE_PORT="${1:-3000}"
GO_PORT="${2:-3001}"
NODE_BASE="http://localhost:${NODE_PORT}"
GO_BASE="http://localhost:${GO_PORT}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
PASS=0
FAIL=0
SKIP=0
ENDPOINTS=(
"/api/stats"
"/api/nodes?limit=5"
"/api/packets?limit=5"
"/api/packets?limit=5&groupByHash=true"
"/api/observers"
"/api/channels"
"/api/channels/public/messages?limit=5"
"/api/analytics/rf?days=7"
"/api/analytics/topology?days=7"
"/api/analytics/hash-sizes?days=7"
"/api/analytics/distance?days=7"
"/api/analytics/subpaths?days=7"
"/api/nodes/bulk-health"
"/api/health"
"/api/perf"
)
# Python helper to extract shape and compare
SHAPE_SCRIPT='
import json, sys
def extract_shape(val, depth=0, max_depth=4):
if val is None:
return "null"
if isinstance(val, bool):
return "boolean"
if isinstance(val, (int, float)):
return "number"
if isinstance(val, str):
return "string"
if isinstance(val, list):
if len(val) > 0 and depth < max_depth:
return {"array": extract_shape(val[0], depth + 1)}
return "array"
if isinstance(val, dict):
if depth >= max_depth:
return "object"
return {k: extract_shape(v, depth + 1) for k, v in sorted(val.items())}
return "unknown"
def compare_shapes(node_shape, go_shape, path="$"):
"""Compare two shapes recursively. Returns list of mismatch strings."""
mismatches = []
if isinstance(node_shape, str) and isinstance(go_shape, str):
# Both are scalar types
if node_shape == "null":
return [] # null in node is OK (nullable field)
if go_shape == "null" and node_shape != "null":
mismatches.append(f"{path}: Node={node_shape}, Go=null")
elif node_shape != go_shape:
mismatches.append(f"{path}: Node={node_shape}, Go={go_shape}")
return mismatches
if isinstance(node_shape, str) and isinstance(go_shape, dict):
mismatches.append(f"{path}: Node={node_shape}, Go=object/array")
return mismatches
if isinstance(node_shape, dict) and isinstance(go_shape, str):
if go_shape == "null":
mismatches.append(f"{path}: Node=object/array, Go=null (nil slice/map?)")
else:
mismatches.append(f"{path}: Node=object/array, Go={go_shape}")
return mismatches
if isinstance(node_shape, dict) and isinstance(go_shape, dict):
# Check for array shape
if "array" in node_shape and "array" not in go_shape:
mismatches.append(f"{path}: Node=array, Go=object")
return mismatches
if "array" in node_shape and "array" in go_shape:
mismatches.extend(compare_shapes(node_shape["array"], go_shape["array"], path + "[0]"))
return mismatches
# Object: check Node keys exist in Go
for key in node_shape:
if key not in go_shape:
mismatches.append(f"{path}: Go missing field \"{key}\" (Node has it)")
else:
mismatches.extend(compare_shapes(node_shape[key], go_shape[key], f"{path}.{key}"))
# Check Go has extra keys not in Node (warning only)
for key in go_shape:
if key not in node_shape:
mismatches.append(f"{path}: Go has extra field \"{key}\" (not in Node) [WARN]")
return mismatches
try:
node_json = json.loads(sys.argv[1])
go_json = json.loads(sys.argv[2])
except (json.JSONDecodeError, IndexError) as e:
print(f"JSON parse error: {e}", file=sys.stderr)
sys.exit(2)
node_shape = extract_shape(node_json)
go_shape = extract_shape(go_json)
mismatches = compare_shapes(node_shape, go_shape)
if mismatches:
for m in mismatches:
print(m)
sys.exit(1)
else:
sys.exit(0)
'
echo "============================================"
echo " Node.js vs Go API Parity Check"
echo " Node: ${NODE_BASE} | Go: ${GO_BASE}"
echo "============================================"
echo ""
for ep in "${ENDPOINTS[@]}"; do
printf "%-50s " "$ep"
# Fetch Node response
node_resp=$(curl -sf "${NODE_BASE}${ep}" 2>/dev/null) || {
printf "${YELLOW}SKIP${NC} (Node unreachable)\n"
SKIP=$((SKIP + 1))
continue
}
# Fetch Go response
go_resp=$(curl -sf "${GO_BASE}${ep}" 2>/dev/null) || {
printf "${YELLOW}SKIP${NC} (Go unreachable)\n"
SKIP=$((SKIP + 1))
continue
}
# Compare shapes
result=$(python3 -c "$SHAPE_SCRIPT" "$node_resp" "$go_resp" 2>&1) || {
printf "${RED}FAIL${NC}\n"
echo "$result" | sed 's/^/ /'
FAIL=$((FAIL + 1))
continue
}
printf "${GREEN}PASS${NC}\n"
PASS=$((PASS + 1))
done
echo ""
echo "============================================"
echo " Results: ${PASS} pass, ${FAIL} fail, ${SKIP} skip"
echo "============================================"
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
exit 0