mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-01 15:20:09 +00:00
- 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>
180 lines
5.5 KiB
Bash
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
|