From 4f21465dbfdfa6910223d4bf982e77524ad44a54 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:47:58 +0100 Subject: [PATCH 1/7] feat(ci): test runner scripts and Makefile integration (#270) Six test runner scripts at tests/: run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration, run-all. Plus run-ipc-benchmark for Layer 3 timing. All produce structured JSON stdout, support --filter, and exit 0/non-zero. Makefile targets updated to delegate to scripts. Co-Authored-By: Claude Opus 4.6 --- Makefile | 34 ++++++++++---- docs/DEVOPS.md | 22 +++++++-- tests/run-all | 77 ++++++++++++++++++++++++++++++ tests/run-godot | 88 +++++++++++++++++++++++++++++++++++ tests/run-ipc-benchmark | 59 +++++++++++++++++++++++ tests/run-ipc-fixtures | 98 +++++++++++++++++++++++++++++++++++++++ tests/run-ipc-integration | 72 ++++++++++++++++++++++++++++ tests/run-ipc-protocol | 50 ++++++++++++++++++++ tests/run-rust | 54 +++++++++++++++++++++ 9 files changed, 542 insertions(+), 12 deletions(-) create mode 100755 tests/run-all create mode 100755 tests/run-godot create mode 100755 tests/run-ipc-benchmark create mode 100755 tests/run-ipc-fixtures create mode 100755 tests/run-ipc-integration create mode 100755 tests/run-ipc-protocol create mode 100755 tests/run-rust diff --git a/Makefile b/Makefile index 6e71150ba..95294d5ee 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,8 @@ GODOT := $(shell command -v godot4 2>/dev/null || command -v godot 2>/dev/null) pre-pr-server pre-pr-client pre-pr-content \ fixtures-client golden-diff golden-update \ checklist-validate checklist-generate \ - perf-baseline debug-schedule + perf-baseline debug-schedule \ + test-ipc-fixtures test-ipc-protocol test-ipc-integration test-ipc-benchmark # --- Configuration --- @@ -23,11 +24,15 @@ help: @echo " make stop Stop any running server instance" @echo " make client Run the Godot client (test mode)" @echo " make server Run the Rust simulation server" - @echo " make test Run all tests" - @echo " make lint Run all linters" - @echo " make ci Run full CI pipeline locally" - @echo " make ci-client Run client CI checks" - @echo " make ci-server Run server CI checks" + @echo " make test Run all tests" + @echo " make test-ipc-fixtures Layer 1: IPC serialization fixtures" + @echo " make test-ipc-protocol Layer 2: mock IPC protocol tests" + @echo " make test-ipc-integration Layer 3: real subprocess round-trip" + @echo " make test-ipc-benchmark IPC latency benchmark (blocked: #555/#556)" + @echo " make lint Run all linters" + @echo " make ci Run full CI pipeline locally" + @echo " make ci-client Run client CI checks" + @echo " make ci-server Run server CI checks" @echo " make check-protocol Verify server/client protocol versions match" @echo " make clean Remove build artifacts and caches" @echo "" @@ -128,7 +133,7 @@ stop: test: test-server test-client test-server: - cd server && cargo nextest run + tests/run-rust fixtures: cd server && cargo test --test gen_fixtures -- --ignored @@ -169,8 +174,19 @@ golden-update: @echo "Review with: git diff --cached -- server/tests/golden/" test-client: - @test -n "$(GODOT)" || { echo "Godot not found. Run 'make setup' first."; exit 1; } - $(GODOT) --headless --path client -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd --ignoreHeadlessMode -a res://tests/ + tests/run-godot + +test-ipc-fixtures: + tests/run-ipc-fixtures + +test-ipc-protocol: + tests/run-ipc-protocol + +test-ipc-integration: + tests/run-ipc-integration + +test-ipc-benchmark: + tests/run-ipc-benchmark # --- Lint --- diff --git a/docs/DEVOPS.md b/docs/DEVOPS.md index 21d44a440..1e3659119 100644 --- a/docs/DEVOPS.md +++ b/docs/DEVOPS.md @@ -62,11 +62,27 @@ The server must be running before the client connects (subprocess launch will be ### Test ```bash -make test # Run all tests -make test-server # cargo test in server/ -make test-client # gdUnit4 tests (headless runner pending) +make test # Run all tests (test-server + test-client) +make test-server # Rust tests via tests/run-rust (cargo nextest, JSON summary) +make test-client # Godot tests via tests/run-godot (gdUnit4 headless, JSON summary) ``` +The IPC test layers (D-030) have dedicated targets: + +```bash +make test-ipc-fixtures # Layer 1: serialization round-trip fixtures +make test-ipc-protocol # Layer 2: mock LocalBridge protocol tests +make test-ipc-integration # Layer 3: real subprocess round-trip (+ benchmark when ready) +make test-ipc-benchmark # IPC latency benchmark (blocked: #555/#556 handshake) +``` + +Each `tests/run-*` script outputs a JSON summary to stdout and streams progress to stderr: +```json +{"suite":"rust","total":42,"passed":42,"failed":0,"duration_ms":1230} +``` + +All scripts accept `--filter ` to run a subset of tests. They are whitelistable for agent use (no TTY prompts, no interactive input). + Server tests use Rust's built-in test framework with `#[cfg(test)]` inline tests and `tests/` integration tests (D-030). Client tests use gdUnit4 (D-030). ### Cross-Encoder Fixtures diff --git a/tests/run-all b/tests/run-all new file mode 100755 index 000000000..b296cfb22 --- /dev/null +++ b/tests/run-all @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# tests/run-all: Run all test suites in order (D-030) +# Invokes run-rust, run-godot, run-ipc-fixtures, run-ipc-protocol, run-ipc-integration. +# Exit: 0 = all suites pass, non-zero = any suite failed +# Stdout: {"suite":"all","total":N,"passed":N,"failed":N,"duration_ms":N,"suites":[...]} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +PASS_ARGS=() +[[ -n "$FILTER" ]] && PASS_ARGS+=(--filter "$FILTER") + +SUITES=( + run-rust + run-godot + run-ipc-fixtures + run-ipc-protocol + run-ipc-integration +) + +START_MS=$(date +%s%3N) + +OVERALL_TOTAL=0 +OVERALL_PASSED=0 +OVERALL_FAILED=0 +OVERALL_EXIT=0 +SUITE_RESULTS="" + +for suite in "${SUITES[@]}"; do + script="$SCRIPT_DIR/$suite" + if [[ ! -x "$script" ]]; then + echo "Warning: $script not found or not executable — skipping" >&2 + continue + fi + + SUITE_OUT=$(mktemp) + set +e + "$script" "${PASS_ARGS[@]}" >"$SUITE_OUT" + SUITE_EXIT=$? + set -e + + SUITE_JSON=$(cat "$SUITE_OUT") + rm -f "$SUITE_OUT" + + # Accumulate totals from the suite's JSON output + S_TOTAL=$(echo "$SUITE_JSON" | grep -oE '"total":[0-9]+' | grep -oE '[0-9]+' || echo 0) + S_PASSED=$(echo "$SUITE_JSON" | grep -oE '"passed":[0-9]+' | grep -oE '[0-9]+' || echo 0) + S_FAILED=$(echo "$SUITE_JSON" | grep -oE '"failed":[0-9]+' | grep -oE '[0-9]+' || echo 0) + + OVERALL_TOTAL=$(( OVERALL_TOTAL + ${S_TOTAL:-0} )) + OVERALL_PASSED=$(( OVERALL_PASSED + ${S_PASSED:-0} )) + OVERALL_FAILED=$(( OVERALL_FAILED + ${S_FAILED:-0} )) + [[ $SUITE_EXIT -ne 0 ]] && OVERALL_EXIT=1 + + # Build suites array for JSON + if [[ -n "$SUITE_RESULTS" ]]; then + SUITE_RESULTS="$SUITE_RESULTS,$SUITE_JSON" + else + SUITE_RESULTS="$SUITE_JSON" + fi +done + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +printf '{"suite":"all","total":%d,"passed":%d,"failed":%d,"duration_ms":%d,"suites":[%s]}\n' \ + "$OVERALL_TOTAL" "$OVERALL_PASSED" "$OVERALL_FAILED" "$DURATION_MS" "$SUITE_RESULTS" +exit $OVERALL_EXIT diff --git a/tests/run-godot b/tests/run-godot new file mode 100755 index 000000000..ee323e275 --- /dev/null +++ b/tests/run-godot @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# tests/run-godot: Run Godot client test suite via gdUnit4 (D-030) +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"godot","total":N,"passed":N,"failed":N,"duration_ms":N} +# +# --filter: accepts a test filename stem (e.g. "test_protocol" → runs test_protocol.gd only) +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "") +if [[ -z "$GODOT" ]]; then + printf '{"suite":"godot","total":0,"passed":0,"failed":0,"duration_ms":0,"error":"godot not found in PATH"}\n' + exit 1 +fi + +# Resolve the test target: directory or specific file +if [[ -n "$FILTER" ]]; then + # Support bare name (test_protocol) or full path (test_protocol.gd) + if [[ "$FILTER" == res://* ]]; then + TEST_TARGET="$FILTER" + elif [[ "$FILTER" == *.gd ]]; then + TEST_TARGET="res://tests/$FILTER" + else + TEST_TARGET="res://tests/${FILTER}.gd" + fi +else + TEST_TARGET="res://tests/" +fi + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +set +e +"$GODOT" --headless --path "$REPO_ROOT/client" \ + -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ + --ignoreHeadlessMode \ + -c \ + -a "$TEST_TARGET" \ + 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oiE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +# gdUnit4 outputs per-suite statistics: "N test cases | X errors | Y failures | ..." +# and a summary: "Executed test cases : (X/N)" or "Executed test cases : (X/N), Z skipped" +TOTAL=0; PASSED=0; FAILED=0 + +# Sum errors + failures across all suite statistics lines +STATS_LINES=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$TMPOUT" || true) +if [[ -n "$STATS_LINES" ]]; then + TOTAL=$(echo "$STATS_LINES" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + ERRORS=$(echo "$STATS_LINES" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILURES=$(echo "$STATS_LINES" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILED=$(( ${ERRORS:-0} + ${FAILURES:-0} )) + PASSED=$(( TOTAL - FAILED )) +fi + +# Fallback: parse "Executed test cases : (X/N)" for total if stats parse failed +if [[ "$TOTAL" -eq 0 ]]; then + EXEC_LINE=$(grep -oE "Executed test cases : \([0-9]+/[0-9]+\)" "$TMPOUT" | tail -1 || true) + if [[ -n "$EXEC_LINE" ]]; then + TOTAL=$(echo "$EXEC_LINE" | grep -oE '/[0-9]+\)' | grep -oE '[0-9]+') + PASSED=$(echo "$EXEC_LINE" | grep -oE '\([0-9]+/' | grep -oE '[0-9]+') + FAILED=$(( TOTAL - PASSED )) + fi +fi + +rm -f "$TMPOUT" +printf '{"suite":"godot","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-benchmark b/tests/run-ipc-benchmark new file mode 100755 index 000000000..dc638e83f --- /dev/null +++ b/tests/run-ipc-benchmark @@ -0,0 +1,59 @@ +#!/usr/bin/env bash +# tests/run-ipc-benchmark: IPC round-trip latency benchmark (#342, D-020) +# +# Runs server/tests/ipc_bench.rs via `cargo test --release --test ipc_bench`. +# Parses IPC_BENCH_RESULT:{json} from output and outputs the result JSON. +# +# Latency budget: p99 <= 5ms (D-020: "~1-5ms serialization latency per tick"). +# +# NOTE (#342): Handshake step is stubbed in ipc_bench.rs pending #555 (server +# protocol handshake) and #556 (client handshake). Full clean timing requires +# a working handshake before the measurement loop starts. +# +# Exit: 0 = benchmark passed (p99 within threshold), non-zero = failure +# Stdout: {"p50_ms":N,"p95_ms":N,"p99_ms":N,"threshold_ms":5,"passed":true,"rounds":100} +set -euo pipefail + +ITERATIONS=100 +THRESHOLD_MS=5 + +while [[ $# -gt 0 ]]; do + case "$1" in + --iterations) ITERATIONS="${2:-100}"; shift 2 ;; + --iterations=*) ITERATIONS="${1#--iterations=}"; shift ;; + --threshold-ms) THRESHOLD_MS="${2:-5}"; shift 2 ;; + --filter) shift 2 ;; # ignored — benchmark has no test filter + --filter=*) shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +set +e +cd "$REPO_ROOT/server" && \ + cargo test --release --test ipc_bench -- --ignored --nocapture 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +# Extract IPC_BENCH_RESULT:{json} line from output +RESULT_LINE=$(grep "^IPC_BENCH_RESULT:" "$TMPOUT" | tail -1 || true) +rm -f "$TMPOUT" + +if [[ -n "$RESULT_LINE" ]]; then + # Strip the prefix and output the JSON + echo "${RESULT_LINE#IPC_BENCH_RESULT:}" +else + # No result line — test failed to produce output + printf '{"p50_ms":0,"p95_ms":0,"p99_ms":0,"threshold_ms":%d,"passed":false,"error":"no benchmark output — server binary may not be built (run make build-server)"}\n' \ + "$THRESHOLD_MS" + EXIT_CODE=1 +fi + +exit $EXIT_CODE diff --git a/tests/run-ipc-fixtures b/tests/run-ipc-fixtures new file mode 100755 index 000000000..6d76a073e --- /dev/null +++ b/tests/run-ipc-fixtures @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# tests/run-ipc-fixtures: Layer 1 IPC fixture tests (D-030) +# Runs Rust serialization round-trip tests + GDScript fixture validation. +# GDScript side is skipped if client/tests/test_ipc_fixtures.gd doesn't exist yet (#271). +# Exit: 0 = all pass, non-zero = any failure +# Stdout: {"suite":"ipc-fixtures","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +_parse_nextest_summary() { + local tmpout="$1" + local summary total passed failed + summary=$(grep -E "^\s*(Summary|Finished)" "$tmpout" | tail -1 || true) + if [[ -n "$summary" ]]; then + total=$(_extract_num "$summary" "tests? run") + passed=$(_extract_num "$summary" "passed") + failed=$(_extract_num "$summary" "failed") + else + total=0; passed=0; failed=0 + fi + echo "$total $passed $failed" +} + +# --- Layer 1a: Rust serialization tests --- +START_MS=$(date +%s%3N) + +cd "$REPO_ROOT/server" +TMPOUT=$(mktemp) +NEXTEST_ARGS=(nextest run --color never --test serialization) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +RUST_EXIT=${PIPESTATUS[0]} +set -e + +read -r RUST_TOTAL RUST_PASSED RUST_FAILED < <(_parse_nextest_summary "$TMPOUT") +rm -f "$TMPOUT" + +# --- Layer 1b: GDScript fixture tests (optional until #271 lands) --- +GDS_FIXTURE="$REPO_ROOT/client/tests/test_ipc_fixtures.gd" +GDS_TOTAL=0; GDS_PASSED=0; GDS_FAILED=0; GDS_EXIT=0 + +if [[ -f "$GDS_FIXTURE" ]]; then + GODOT=$(command -v godot4 2>/dev/null || command -v godot 2>/dev/null || echo "") + if [[ -z "$GODOT" ]]; then + echo "Warning: test_ipc_fixtures.gd found but godot not in PATH — skipping GDScript layer" >&2 + else + GDTMP=$(mktemp) + set +e + "$GODOT" --headless --path "$REPO_ROOT/client" \ + -s res://addons/gdUnit4/bin/GdUnitCmdTool.gd \ + --ignoreHeadlessMode -c \ + -a res://tests/test_ipc_fixtures.gd \ + 2>&1 | tee "$GDTMP" >&2 + GDS_EXIT=${PIPESTATUS[0]} + set -e + + STATS=$(grep -oE "[0-9]+ test cases \| [0-9]+ errors \| [0-9]+ failures" "$GDTMP" || true) + if [[ -n "$STATS" ]]; then + GDS_TOTAL=$(echo "$STATS" | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + ERRS=$(echo "$STATS" | grep -oE '[0-9]+ errors' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + FAILS=$(echo "$STATS" | grep -oE '[0-9]+ failures' | grep -oE '^[0-9]+' | awk '{s+=$1} END {print s}') + GDS_FAILED=$(( ${ERRS:-0} + ${FAILS:-0} )) + GDS_PASSED=$(( GDS_TOTAL - GDS_FAILED )) + fi + rm -f "$GDTMP" + fi +fi + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +TOTAL=$(( RUST_TOTAL + GDS_TOTAL )) +PASSED=$(( RUST_PASSED + GDS_PASSED )) +FAILED=$(( RUST_FAILED + GDS_FAILED )) + +# Overall exit: fail if either side failed +EXIT_CODE=$(( RUST_EXIT != 0 || GDS_EXIT != 0 ? 1 : 0 )) + +printf '{"suite":"ipc-fixtures","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-integration b/tests/run-ipc-integration new file mode 100755 index 000000000..5f0696e71 --- /dev/null +++ b/tests/run-ipc-integration @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# tests/run-ipc-integration: Layer 3 real-subprocess integration tests (D-030) +# Spawns the server binary as a real child process, runs IPC round-trip. +# Also invokes tests/run-ipc-benchmark when that script exists (#342). +# Exit: 0 = all pass, non-zero = any failure +# Stdout: {"suite":"ipc-integration","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never --test layer3) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +LAYER3_EXIT=${PIPESTATUS[0]} +set -e + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi +rm -f "$TMPOUT" + +# Run IPC benchmark if it exists (#342 — requires handshake from #555/#556) +BENCH_SCRIPT="$REPO_ROOT/tests/run-ipc-benchmark" +BENCH_EXIT=0 +if [[ -x "$BENCH_SCRIPT" ]]; then + BENCH_ARGS=() + [[ -n "$FILTER" ]] && BENCH_ARGS+=(--filter "$FILTER") + set +e + "$BENCH_SCRIPT" "${BENCH_ARGS[@]}" >&2 + BENCH_EXIT=$? + set -e + if [[ $BENCH_EXIT -ne 0 ]]; then + FAILED=$(( FAILED + 1 )) + TOTAL=$(( TOTAL + 1 )) + else + PASSED=$(( PASSED + 1 )) + TOTAL=$(( TOTAL + 1 )) + fi +fi + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +EXIT_CODE=$(( LAYER3_EXIT != 0 || BENCH_EXIT != 0 ? 1 : 0 )) + +printf '{"suite":"ipc-integration","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "$TOTAL" "$PASSED" "$FAILED" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-ipc-protocol b/tests/run-ipc-protocol new file mode 100755 index 000000000..166a05a53 --- /dev/null +++ b/tests/run-ipc-protocol @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +# tests/run-ipc-protocol: Layer 2 mock IPC protocol tests (D-030) +# Runs LocalBridge Unix-socket round-trip tests (no real subprocess). +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"ipc-protocol","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never --test bridge_ipc) +[[ -n "$FILTER" ]] && NEXTEST_ARGS+=(-E "test(~${FILTER})") + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi + +rm -f "$TMPOUT" +printf '{"suite":"ipc-protocol","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE diff --git a/tests/run-rust b/tests/run-rust new file mode 100755 index 000000000..453a00b1b --- /dev/null +++ b/tests/run-rust @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# tests/run-rust: Run Rust test suite via cargo nextest (D-030) +# Exit: 0 = all pass, non-zero = failure +# Stdout: {"suite":"rust","total":N,"passed":N,"failed":N,"duration_ms":N} +set -euo pipefail + +FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --filter) FILTER="${2:-}"; shift 2 ;; + --filter=*) FILTER="${1#--filter=}"; shift ;; + *) echo "Unknown argument: $1" >&2; exit 2 ;; + esac +done + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT/server" + +START_MS=$(date +%s%3N) +TMPOUT=$(mktemp) + +NEXTEST_ARGS=(nextest run --color never) +if [[ -n "$FILTER" ]]; then + NEXTEST_ARGS+=(-E "test(~${FILTER})") +fi + +set +e +cargo "${NEXTEST_ARGS[@]}" 2>&1 | tee "$TMPOUT" >&2 +EXIT_CODE=${PIPESTATUS[0]} +set -e + +END_MS=$(date +%s%3N) +DURATION_MS=$((END_MS - START_MS)) + +# Parse nextest summary: " Summary [ 0.123s] N tests run: X passed[, Y failed], Z skipped" +# (older nextest uses "Finished", newer uses "Summary" — match both) +SUMMARY=$(grep -E "^\s*(Summary|Finished)" "$TMPOUT" | tail -1 || true) + +_extract_num() { + local haystack="$1" pattern="$2" + echo "$haystack" | grep -oE "[0-9]+ $pattern" | grep -oE '^[0-9]+' || echo 0 +} + +TOTAL=0; PASSED=0; FAILED=0 +if [[ -n "$SUMMARY" ]]; then + TOTAL=$(_extract_num "$SUMMARY" "tests? run") + PASSED=$(_extract_num "$SUMMARY" "passed") + FAILED=$(_extract_num "$SUMMARY" "failed") +fi + +rm -f "$TMPOUT" +printf '{"suite":"rust","total":%d,"passed":%d,"failed":%d,"duration_ms":%d}\n' \ + "${TOTAL:-0}" "${PASSED:-0}" "${FAILED:-0}" "$DURATION_MS" +exit $EXIT_CODE From 7d5fdc82b6db97e8258d2698d39b8ff81b7182d0 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:48:07 +0100 Subject: [PATCH 2/7] feat(ci): IPC serialization fixture files and cross-language tests (#271) Five new .msgpack fixtures: snapshot_minimal, snapshot_full, player_input_move, player_input_interact, malformed. Rust generator in gen_fixtures.rs, 7 Rust validation tests in serialization.rs, GDScript cross-language test in test_ipc_fixtures.gd (22 assertions). Co-Authored-By: Claude Opus 4.6 --- .../tests/fixtures/msgpack/malformed.msgpack | 1 + .../msgpack/player_input_interact.msgpack | 1 + .../msgpack/player_input_move.msgpack | 1 + .../fixtures/msgpack/snapshot_full.msgpack | Bin 0 -> 1233 bytes .../fixtures/msgpack/snapshot_minimal.msgpack | Bin 0 -> 474 bytes client/tests/test_ipc_fixtures.gd | 182 ++++++++++++++++++ server/tests/gen_fixtures.rs | 147 +++++++++++++- server/tests/serialization.rs | 148 +++++++++++++- 8 files changed, 473 insertions(+), 7 deletions(-) create mode 100644 client/tests/fixtures/msgpack/malformed.msgpack create mode 100644 client/tests/fixtures/msgpack/player_input_interact.msgpack create mode 100644 client/tests/fixtures/msgpack/player_input_move.msgpack create mode 100644 client/tests/fixtures/msgpack/snapshot_full.msgpack create mode 100644 client/tests/fixtures/msgpack/snapshot_minimal.msgpack create mode 100644 client/tests/test_ipc_fixtures.gd diff --git a/client/tests/fixtures/msgpack/malformed.msgpack b/client/tests/fixtures/msgpack/malformed.msgpack new file mode 100644 index 000000000..bd2e3507c --- /dev/null +++ b/client/tests/fixtures/msgpack/malformed.msgpack @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_interact.msgpack b/client/tests/fixtures/msgpack/player_input_interact.msgpack new file mode 100644 index 000000000..c2963fa57 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_interact.msgpack @@ -0,0 +1 @@ +tickactionInteracttarget_entity_idcverbTalk \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/player_input_move.msgpack b/client/tests/fixtures/msgpack/player_input_move.msgpack new file mode 100644 index 000000000..2d733e444 --- /dev/null +++ b/client/tests/fixtures/msgpack/player_input_move.msgpack @@ -0,0 +1 @@ +tickactionMoveNorth \ No newline at end of file diff --git a/client/tests/fixtures/msgpack/snapshot_full.msgpack b/client/tests/fixtures/msgpack/snapshot_full.msgpack new file mode 100644 index 0000000000000000000000000000000000000000..265612bdbe9553217ddce8fa72c13aacda301aa4 GIT binary patch literal 1233 zcma)+&u$z=5XJ{Ah?E0YZm1=Mgd(&GaL6?#PJ|SsAn_;1QEzwcY-@VDhwh&BPEHXf zfuol`xZr8jSW+60=07ax6N3kqZ4VIv~#nH`N7AKEG~SUm$9L4z|{0V))xo`f~h zKfnDrN&YIyNO-0W#>@Z&N|-&}khm5}-0n#vI36W`Ix4t|twIeV_gkxA1J|hpfx0(c zUrEI~;fU)?grp~}R)&D~B#F{zf{n2(ti)!OEA!bBw{84vCAk zNdS=pKuOcL7i(ked2=6`PQBnJr8pLT|7@tO$Vv8L(DPs4d3WJ$Wnk%FTmVfR;GWcL zx^la`x$fK}-UoQWCH7~77wen&1^eA4H zTuNw(6oS% zu%su;?>+rwSIq<@BGctOq06)7(2l_*9K-XQw^g=kQ^Nz(7E8TMF|rYwYZgKpF54j^ zHo?4ChOjAutw7AcI&Hq+u%yu$i^-CM`GXDl_U{?e1Sqjo^+F>ig!uZh?9$4hznDFK zXL@GUDLiHK9Lay&b|jw@jM&G3scL#GQ!9Qg5NuL2afC&4M>+A7R@{#ulMZk0+nE5H zZZ@<=1H}43V5P5=lt8TOeyDe@6j3c^%*26*B^e9&OiGI2MY) zaTT2<1FgGQZ~K8=4&IzS*6YY6X?^M+9P1t=b_&nn{y#__e7zoU2s|~q$ppu}8`)N@ SuDej;SPC2$1)~WVQ~e9mK+T!} literal 0 HcmV?d00001 diff --git a/client/tests/test_ipc_fixtures.gd b/client/tests/test_ipc_fixtures.gd new file mode 100644 index 000000000..12594b279 --- /dev/null +++ b/client/tests/test_ipc_fixtures.gd @@ -0,0 +1,182 @@ +## D-030 Layer 1: Cross-language IPC fixture tests (#271) +## Validates that Protocol.gd decodes the #271 named fixtures identically to Rust. +## Fixtures generated by: cargo test --test gen_fixtures -- --ignored +## Rust validation: server/tests/serialization.rs (fixture_* tests) +class_name TestIpcFixtures +extends GdUnitTestSuite + +const FIXTURE_DIR = "res://tests/fixtures/msgpack/" + + +func _load_fixture(name: String) -> PackedByteArray: + var path = FIXTURE_DIR + name + ".msgpack" + var file = FileAccess.open(path, FileAccess.READ) + assert_that(file).is_not_null().override_failure_message( + "Fixture not found: %s — run 'make fixtures' to regenerate" % path + ) + return file.get_buffer(file.get_length()) + + +# -- snapshot_minimal ---------------------------------------------------------- + +func test_fixture_snapshot_minimal_version() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.version).is_equal(Protocol.PROTOCOL_VERSION) + + +func test_fixture_snapshot_minimal_tick() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.tick).is_equal(0) + + +func test_fixture_snapshot_minimal_entity_count() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.entities.size()).is_equal(1) + + +func test_fixture_snapshot_minimal_entity_kind() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + var entity = snapshot.entities[0] + assert_that(entity.entity_id).is_equal(1) + # entity.kind is {"variant": "Player", "data": null} from _decode_enum_variant + assert_that(entity.kind.variant).is_equal("Player") + + +func test_fixture_snapshot_minimal_no_monologue() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue).is_null() + + +func test_fixture_snapshot_minimal_no_dialogue() -> void: + var bytes = _load_fixture("snapshot_minimal") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.dialogue_response).is_null() + + +# -- snapshot_full ------------------------------------------------------------- + +func test_fixture_snapshot_full_tick() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot).is_not_null() + assert_that(snapshot.tick).is_equal(42) + + +func test_fixture_snapshot_full_monologue_id() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue).is_not_null() + assert_that(snapshot.current_monologue.id).is_equal("test_monologue_001") + + +func test_fixture_snapshot_full_monologue_text() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.current_monologue.text).is_equal("Something feels off about this place.") + + +func test_fixture_snapshot_full_dialogue_speaker() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.dialogue_response).is_not_null() + # dialogue_response has: line_id, text, speaker_entity_id (per protocol.gd v8 decode) + assert_that(snapshot.dialogue_response.speaker_entity_id).is_equal(99) + + +func test_fixture_snapshot_full_inventory() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_inventory.size()).is_equal(1) + assert_that(snapshot.player_inventory[0].name).is_equal("Forged Customs Cert") + + +func test_fixture_snapshot_full_poi_list() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.poi_list.size()).is_equal(1) + assert_that(snapshot.poi_list[0].poi_id).is_equal("docking_bay_7") + + +func test_fixture_snapshot_full_player_knowledge_entity() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_knowledge).is_not_null() + assert_that(snapshot.player_knowledge.entities.size()).is_equal(1) + assert_that(snapshot.player_knowledge.entities[0].name).is_equal("Kael") + + +func test_fixture_snapshot_full_player_knowledge_fact() -> void: + var bytes = _load_fixture("snapshot_full") + var snapshot = Protocol.decode_snapshot(bytes) + assert_that(snapshot.player_knowledge.facts.size()).is_equal(1) + assert_that(snapshot.player_knowledge.facts[0].fact_id).is_equal("poi.docking_bay_7") + + +# -- player_input_move --------------------------------------------------------- + +func test_fixture_player_input_move_tick() -> void: + var bytes = _load_fixture("player_input_move") + var input = Protocol.decode_player_input(bytes) + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(1) + + +func test_fixture_player_input_move_action() -> void: + var bytes = _load_fixture("player_input_move") + var input = Protocol.decode_player_input(bytes) + # action is {"variant": "MoveNorth", "data": null} from _decode_enum_variant + assert_that(input.action.variant).is_equal("MoveNorth") + + +# -- player_input_interact ----------------------------------------------------- + +func test_fixture_player_input_interact_tick() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input).is_not_null() + assert_that(input.tick).is_equal(2) + + +func test_fixture_player_input_interact_action() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + # Interact is a struct variant: {"variant": "Interact", "data": {"target_entity_id": 99, "verb": "Talk"}} + assert_that(input.action.variant).is_equal("Interact") + + +func test_fixture_player_input_interact_target() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input.action.data.target_entity_id).is_equal(99) + + +func test_fixture_player_input_interact_verb() -> void: + var bytes = _load_fixture("player_input_interact") + var input = Protocol.decode_player_input(bytes) + assert_that(input.action.data.verb).is_equal("Talk") + + +# -- malformed ----------------------------------------------------------------- + +func test_fixture_malformed_snapshot_fails() -> void: + var bytes = _load_fixture("malformed") + # Intentionally truncated — decode_snapshot must return null (not crash) + var result = Protocol.decode_snapshot(bytes) + assert_that(result).is_null().override_failure_message( + "malformed fixture should not decode as a valid ObserverSnapshot" + ) + + +func test_fixture_malformed_input_fails() -> void: + var bytes = _load_fixture("malformed") + # Intentionally truncated — decode_player_input must return null (not crash) + var result = Protocol.decode_player_input(bytes) + assert_that(result).is_null().override_failure_message( + "malformed fixture should not decode as a valid PlayerInput" + ) diff --git a/server/tests/gen_fixtures.rs b/server/tests/gen_fixtures.rs index b32a4f1ae..78ce37016 100644 --- a/server/tests/gen_fixtures.rs +++ b/server/tests/gen_fixtures.rs @@ -2,6 +2,7 @@ //! Run with: cargo test --test gen_fixtures -- --ignored use settled_reach_server::bridge::types::*; +use settled_reach_server::simulation::poi::PoiCategory; use settled_reach_server::simulation::time::{DayPhase, TickRate}; use std::fs; use std::path::Path; @@ -41,7 +42,6 @@ fn fixture_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -232,7 +232,6 @@ fn generate_msgpack_fixtures() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -287,6 +286,150 @@ fn generate_msgpack_fixtures() { write_fixture(name, &rmp_serde::to_vec_named(&input).unwrap()); } + // === #271 fixtures: named fixtures for cross-language Layer 1 testing === + + // snapshot_minimal: version=14, tick=0, one Player entity, all optionals absent + let snapshot_minimal = fixture_snapshot( + 0, + vec![VisibleEntity { + entity_id: 1, + x: 0.0, + y: 0.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + ); + write_fixture( + "snapshot_minimal", + &rmp_serde::to_vec_named(&snapshot_minimal).unwrap(), + ); + + // snapshot_full: version=14, tick=42, monologue + dialogue + inventory + POIs + KG dump + let snapshot_full = ObserverSnapshot { + version: PROTOCOL_VERSION, + tick: 42, + game_time: GameTime { + day: 3, + time_of_day: 840, + day_phase: DayPhase::Evening, + tick_rate: TickRate::Full, + }, + player_facing: FacingDirection::East, + player_stance: MovementStance::Walk, + player_inventory: vec![InventoryItem { + item_id: 7, + name: "Forged Customs Cert".to_string(), + slot: 0, + }], + entities: vec![VisibleEntity { + entity_id: 1, + x: 10.0, + y: 10.0, + z: 0, + kind: EntityKind::Player, + visibility: VisibilitySector::Forward, + relationship: RelationshipState::Unknown, + observation: EntityVisibility::Visible, + tell_state: None, + }], + visible_tiles: vec![], + nearby_interactions: vec![], + current_monologue: Some(MonologueEvent { + id: "test_monologue_001".to_string(), + text: "Something feels off about this place.".to_string(), + duration_seconds: 4.0, + }), + pending_recognitions: vec![PendingRecognitionWire { + entity_id: 7, + x: 12.0, + y: 8.0, + z: 0, + remaining_ticks: 3, + total_delay_ticks: 10, + }], + dialogue_response: Some(DialogueResponseEvent { + line_id: "kael_d_001".to_string(), + text: "We need to talk about the shipment.".to_string(), + speaker_entity_id: 99, + speaker_color_index: 2, + speaker_name: "Kael".to_string(), + }), + blocked_entities: vec![5, 6], + scan_events: vec![], + sound_events: vec![], + conversation_events: vec![], + conversation_ended: vec![], + follow_state: None, + character_pressure: None, + rng_seed: Some(0xDEADBEEF), + poi_list: vec![PoiWire { + poi_id: "docking_bay_7".to_string(), + name: "Docking Bay 7".to_string(), + x: 50, + y: 30, + z: 0, + category: PoiCategory::Location, + }], + examine_result: Some(ExamineResultWire { + entity_id: 42, + text: "A smuggler, probably. The way they hold themselves.".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + }), + player_knowledge: Some(PlayerKnowledgeWire { + entities: vec![KnownEntityWire { + entity_id: 99, + name: "Kael".to_string(), + confidence: KnowledgeConfidence::KnowsDetails, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + relationship: RelationshipState::Known, + last_observed_tick: 40, + }], + facts: vec![KnownFactWire { + fact_id: "poi.docking_bay_7".to_string(), + confidence: KnowledgeConfidence::KnowsOf, + source: "DirectObservation".to_string(), + state: KnowledgeState::Active, + acquired_tick: 10, + }], + }), + }; + write_fixture( + "snapshot_full", + &rmp_serde::to_vec_named(&snapshot_full).unwrap(), + ); + + // player_input_move: tick=1, MoveNorth + let input_move = PlayerInput { + tick: 1, + action: PlayerAction::MoveNorth, + }; + write_fixture( + "player_input_move", + &rmp_serde::to_vec_named(&input_move).unwrap(), + ); + + // player_input_interact: tick=2, Interact { target: 99, verb: "Talk" } + let input_interact = PlayerInput { + tick: 2, + action: PlayerAction::Interact { + target_entity_id: Some(99), + verb: Some("Talk".to_string()), + }, + }; + write_fixture( + "player_input_interact", + &rmp_serde::to_vec_named(&input_interact).unwrap(), + ); + + // malformed: intentionally truncated bytes — tests error handling in both Rust and GDScript + // 0x82 = fixmap with 2 entries, 0xa4 = fixstr of length 4 — incomplete map, no key/value follows + write_fixture("malformed", &[0x82u8, 0xa4u8]); + // === Boundary value fixtures (#472) === // 14 raw integer values at encoding format boundaries (Appendix C). // These are Rust-encoded MessagePack that GDScript must decode correctly. diff --git a/server/tests/serialization.rs b/server/tests/serialization.rs index 91d0c6bf4..58e5b9dec 100644 --- a/server/tests/serialization.rs +++ b/server/tests/serialization.rs @@ -30,7 +30,6 @@ fn test_snapshot(tick: u64, entities: Vec) -> ObserverSnapshot { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -174,13 +173,15 @@ fn all_fixtures_deserialize() { } else if name.starts_with("input_batch") { rmp_serde::from_slice::>(&bytes) .unwrap_or_else(|e| panic!("deserialize batch input fixture {}: {}", name, e)); - } else if name.starts_with("input") { + } else if name.starts_with("input") || name.starts_with("player_input") { rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize input fixture {}: {}", name, e)); } else if name.starts_with("boundary_raw") { // Raw integer boundary fixtures (#472): single u64 values rmp_serde::from_slice::(&bytes) .unwrap_or_else(|e| panic!("deserialize boundary raw fixture {}: {}", name, e)); + } else if name == "malformed" { + // Intentionally truncated — skip deserialization check, error handling tested elsewhere } else { panic!( "unknown fixture naming convention: {} — add a deserialization branch for this prefix", @@ -280,7 +281,6 @@ fn snapshot_v2_fields_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -384,7 +384,6 @@ fn all_facing_direction_variants_roundtrip() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], @@ -1394,7 +1393,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() { // `tell_state`, `follow_state`, `rng_seed`, `zone_id`, `object_type`, etc. // are all `#[serde(default)]` — they must default to None/empty when absent. let minimal_json = serde_json::json!({ - "version": 14, + "version": 13, "tick": 42, "game_time": { "day": 0, @@ -1642,3 +1641,142 @@ fn nearby_interaction_object_type_roundtrip() { Some(ObjectType::Container) ); } + +// ============================================================ +// #271: Named fixture validation tests (D-030 Layer 1) +// +// These tests read the committed .msgpack files and assert specific field +// values. They serve as the Rust side of cross-language verification — the +// same fixtures are decoded by client/tests/test_ipc_fixtures.gd. +// ============================================================ + +fn read_named_fixture(name: &str) -> Vec { + let path = format!("../client/tests/fixtures/msgpack/{}.msgpack", name); + fs::read(&path).unwrap_or_else(|e| panic!("failed to read fixture '{}': {}", name, e)) +} + +#[test] +fn fixture_snapshot_minimal_fields() { + let bytes = read_named_fixture("snapshot_minimal"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_minimal"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 0, "tick should be 0"); + assert_eq!(snap.entities.len(), 1, "should have exactly 1 entity"); + assert_eq!(snap.entities[0].entity_id, 1); + assert!( + matches!(snap.entities[0].kind, EntityKind::Player), + "entity should be Player kind" + ); + assert!(snap.current_monologue.is_none(), "no monologue in minimal"); + assert!(snap.dialogue_response.is_none(), "no dialogue in minimal"); + assert!(snap.player_inventory.is_empty(), "no inventory in minimal"); + assert!(snap.poi_list.is_empty(), "no POIs in minimal"); + assert!(snap.player_knowledge.is_none(), "no KG in minimal"); +} + +#[test] +fn fixture_snapshot_full_fields() { + let bytes = read_named_fixture("snapshot_full"); + let snap: ObserverSnapshot = + rmp_serde::from_slice(&bytes).expect("deserialize snapshot_full"); + + assert_eq!(snap.version, PROTOCOL_VERSION, "protocol version mismatch"); + assert_eq!(snap.tick, 42, "tick should be 42"); + + // Monologue + let monologue = snap.current_monologue.as_ref().expect("monologue absent"); + assert_eq!(monologue.id, "test_monologue_001"); + assert_eq!( + monologue.text, + "Something feels off about this place." + ); + + // Dialogue + let dialogue = snap.dialogue_response.as_ref().expect("dialogue absent"); + assert_eq!(dialogue.speaker_entity_id, 99); + assert_eq!(dialogue.speaker_name, "Kael"); + + // Inventory + assert_eq!(snap.player_inventory.len(), 1); + assert_eq!(snap.player_inventory[0].name, "Forged Customs Cert"); + + // POIs + assert_eq!(snap.poi_list.len(), 1); + assert_eq!(snap.poi_list[0].poi_id, "docking_bay_7"); + + // Examine result + let examine = snap.examine_result.as_ref().expect("examine_result absent"); + assert_eq!(examine.entity_id, 42); + + // Player knowledge + let kg = snap.player_knowledge.as_ref().expect("player_knowledge absent"); + assert_eq!(kg.entities.len(), 1); + assert_eq!(kg.entities[0].name, "Kael"); + assert_eq!(kg.facts.len(), 1); + assert_eq!(kg.facts[0].fact_id, "poi.docking_bay_7"); + + // RNG seed + assert_eq!(snap.rng_seed, Some(0xDEADBEEF)); + + // Pending recognitions + assert_eq!(snap.pending_recognitions.len(), 1); + assert_eq!(snap.pending_recognitions[0].entity_id, 7); + + // Blocked entities + assert_eq!(snap.blocked_entities, vec![5u64, 6]); +} + +#[test] +fn fixture_player_input_move_fields() { + let bytes = read_named_fixture("player_input_move"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_move"); + + assert_eq!(input.tick, 1, "tick should be 1"); + assert!( + matches!(input.action, PlayerAction::MoveNorth), + "action should be MoveNorth" + ); +} + +#[test] +fn fixture_player_input_interact_fields() { + let bytes = read_named_fixture("player_input_interact"); + let input: PlayerInput = + rmp_serde::from_slice(&bytes).expect("deserialize player_input_interact"); + + assert_eq!(input.tick, 2, "tick should be 2"); + match &input.action { + PlayerAction::Interact { + target_entity_id, + verb, + } => { + assert_eq!(*target_entity_id, Some(99u64), "target_entity_id should be Some(99)"); + assert_eq!( + verb.as_deref(), + Some("Talk"), + "verb should be Some(\"Talk\")" + ); + } + other => panic!("expected Interact, got {:?}", other), + } +} + +#[test] +fn fixture_malformed_fails_deserialization() { + let bytes = read_named_fixture("malformed"); + // Intentionally truncated — must NOT deserialize as ObserverSnapshot + let result = rmp_serde::from_slice::(&bytes); + assert!( + result.is_err(), + "malformed fixture should fail to deserialize as ObserverSnapshot" + ); + // Also must NOT deserialize as PlayerInput + let result2 = rmp_serde::from_slice::(&bytes); + assert!( + result2.is_err(), + "malformed fixture should fail to deserialize as PlayerInput" + ); +} From 732b5f3adcf61e7bc6b179ff2b815ee840aee0cf Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:48:17 +0100 Subject: [PATCH 3/7] fix(simulation): resolve duplicate examine_result field and regenerate fixtures Removed duplicate examine_result from ObserverSnapshot (old ExamineResultEvent superseded by ExamineResultWire). Fixed serde_default test version assertion. Regenerated golden file and all fixture .msgpack files. 1070 Rust tests passing. Co-Authored-By: Claude Opus 4.6 --- .../msgpack/snapshot_boundary_tick_0.msgpack | Bin 379 -> 373 bytes .../msgpack/snapshot_boundary_tick_127.msgpack | Bin 379 -> 373 bytes .../snapshot_boundary_tick_2b31m1.msgpack | Bin 383 -> 377 bytes .../msgpack/snapshot_boundary_tick_2b32.msgpack | Bin 387 -> 381 bytes .../snapshot_boundary_tick_32767.msgpack | Bin 381 -> 375 bytes .../fixtures/msgpack/snapshot_empty.msgpack | Bin 379 -> 373 bytes .../msgpack/snapshot_multi_entity.msgpack | Bin 784 -> 778 bytes .../fixtures/msgpack/snapshot_one_npc.msgpack | Bin 477 -> 471 bytes .../fixtures/msgpack/snapshot_player.msgpack | Bin 480 -> 474 bytes .../fixtures/msgpack/snapshot_v2_full.msgpack | Bin 644 -> 638 bytes server/src/bridge/text_renderer.rs | 2 -- server/src/bridge/types.rs | 5 ----- server/src/perception/observer/mod.rs | 9 +++++++-- server/tests/bridge_ipc.rs | 1 - server/tests/bridge_tcp.rs | 1 - server/tests/golden/proof_room_tick_10.json | 1 - 16 files changed, 7 insertions(+), 12 deletions(-) diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_0.msgpack index 148ce3bcd558bbbf951cc30443441e6a809957b2..5fec55ecc517ad10a73f8817a78aac057a4de6d7 100644 GIT binary patch delta 38 ucmey(^p$BtIHTOA{>!u>o>6X7az668YDHphW?pK1QEG8%PRW5y$r*`7iOD6YMezkdq2kh_)B^yWXA=Yf diff --git a/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack b/client/tests/fixtures/msgpack/snapshot_boundary_tick_32767.msgpack index 304069bcb2e90efaa885cbc3123132cee31db23a..88a39e51d53269b14d4f666b84f41003f206f4d8 100644 GIT binary patch delta 38 ucmey%^qpx#B%|D>@KO2wVUF delta 27 icmeyz(!#nSfr)=zYDHphW?pK1QEG8%PRZnWCQ$&Dy$VSH diff --git a/server/src/bridge/text_renderer.rs b/server/src/bridge/text_renderer.rs index df789e107..1a2b69da0 100644 --- a/server/src/bridge/text_renderer.rs +++ b/server/src/bridge/text_renderer.rs @@ -306,7 +306,6 @@ mod tests { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, sound_events: vec![], rng_seed: None, @@ -441,7 +440,6 @@ mod tests { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, sound_events: vec![], rng_seed: None, diff --git a/server/src/bridge/types.rs b/server/src/bridge/types.rs index fdec7a988..afdac6564 100644 --- a/server/src/bridge/types.rs +++ b/server/src/bridge/types.rs @@ -110,11 +110,6 @@ pub struct ObserverSnapshot { /// Client shows follow indicator with distance, LOS, and tension. #[serde(default)] pub follow_state: Option, - /// Examine result from Examine verb interaction (#242). - /// Present when the player examined an NPC or object this tick. - /// Client displays character-filtered detail text in an observation panel. - #[serde(default)] - pub examine_result: Option, /// Character pressure state for client HUD widget (#248). /// Present when pressure is non-zero. Client renders tension indicator. #[serde(default)] diff --git a/server/src/perception/observer/mod.rs b/server/src/perception/observer/mod.rs index bf82678bf..2feaa6886 100644 --- a/server/src/perception/observer/mod.rs +++ b/server/src/perception/observer/mod.rs @@ -203,7 +203,13 @@ pub fn compute_observer_snapshot( let current_monologue = monologue_buffer.take(); let dialogue_response = dialogue_response_opt.as_mut().and_then(|buf| buf.take()); - let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take()); + let examine_result = examine_result_buffer_opt.as_mut().and_then(|buf| buf.take()).map( + |evt| crate::bridge::types::ExamineResultWire { + entity_id: evt.target_entity_id, + text: evt.text, + confidence: crate::knowledge::types::KnowledgeConfidence::KnowsDetails, + }, + ); let scan_events = scan_event_buffer_opt .as_mut() .map(|buf| buf.take()) @@ -403,7 +409,6 @@ pub fn compute_observer_snapshot( sound_events, rng_seed: sim_rng.as_deref().map(|r| r.seed()), poi_list, - examine_result: None, // Populated by examine system when #242 lands player_knowledge, }); } diff --git a/server/tests/bridge_ipc.rs b/server/tests/bridge_ipc.rs index ecb056bf8..bc2f7c2cf 100644 --- a/server/tests/bridge_ipc.rs +++ b/server/tests/bridge_ipc.rs @@ -66,7 +66,6 @@ fn snapshot_roundtrip_over_unix_socket() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], diff --git a/server/tests/bridge_tcp.rs b/server/tests/bridge_tcp.rs index 3ea63a72e..3c515249b 100644 --- a/server/tests/bridge_tcp.rs +++ b/server/tests/bridge_tcp.rs @@ -52,7 +52,6 @@ fn snapshot_roundtrip_over_tcp() { conversation_events: vec![], conversation_ended: vec![], follow_state: None, - examine_result: None, character_pressure: None, rng_seed: None, poi_list: vec![], diff --git a/server/tests/golden/proof_room_tick_10.json b/server/tests/golden/proof_room_tick_10.json index 95752ac95..fdbc7e163 100644 --- a/server/tests/golden/proof_room_tick_10.json +++ b/server/tests/golden/proof_room_tick_10.json @@ -39,7 +39,6 @@ "z": 0 } ], - "examine_result": null, "follow_state": null, "game_time": { "day": 0, From 81ec337e4d89321071a0ffd0a577a9f797783abe Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:48:25 +0100 Subject: [PATCH 4/7] feat(ci): IPC round-trip timing benchmark scaffold (#342) Benchmark test in ipc_bench.rs: warmup + 100 rounds, p50/p95/p99 latency reporting, 5ms threshold. Handshake step stubbed pending #555/#556. Invoked via tests/run-ipc-benchmark. Co-Authored-By: Claude Opus 4.6 --- server/tests/ipc_bench.rs | 195 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 server/tests/ipc_bench.rs diff --git a/server/tests/ipc_bench.rs b/server/tests/ipc_bench.rs new file mode 100644 index 000000000..dc8776f6e --- /dev/null +++ b/server/tests/ipc_bench.rs @@ -0,0 +1,195 @@ +//! IPC round-trip latency benchmark (#342, D-020) +//! +//! Measures end-to-end latency from client send (write_framed) to client receive +//! (read_framed) over the real subprocess IPC channel. Reports p50/p95/p99. +//! +//! Latency budget: p99 must be <= 5ms (D-020: "~1-5ms serialization latency per tick"). +//! +//! Run with: +//! cargo test --release --test ipc_bench -- --ignored --nocapture +//! +//! Output: IPC_BENCH_RESULT:{json} on a single line for tooling to parse. +//! +//! BLOCKED (#342): Handshake step is stubbed pending #555 (server) + #556 (client). +//! The test currently skips the HandshakeMessage exchange and starts timing +//! immediately after TCP connection is established. +//! +//! Spec references: D-020 (subprocess IPC, 5ms budget), D-030 (Layer 3) + +use settled_reach_server::bridge::framing::{read_framed, write_framed}; +use settled_reach_server::bridge::types::*; +use std::io::{BufRead, BufReader, BufWriter}; +use std::net::TcpStream; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +/// Number of warmup round-trips before timing begins. +const WARMUP_ROUNDS: usize = 10; + +/// Number of timed round-trips (N in the spec). +const MEASURE_ROUNDS: usize = 100; + +/// Latency threshold (p99 must be below this). D-020: "~1-5ms per tick". +const THRESHOLD_MS: f64 = 5.0; + +/// Timeout for the server to emit LISTENING:{port} on stdout. +const LISTEN_TIMEOUT: Duration = Duration::from_secs(15); + +/// Timeout per round-trip read. +const ROUND_TRIP_TIMEOUT: Duration = Duration::from_secs(5); + +fn percentile(sorted: &[f64], p: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let idx = ((sorted.len() - 1) as f64 * p).floor() as usize; + sorted[idx.min(sorted.len() - 1)] +} + +#[test] +#[ignore] +fn ipc_round_trip_latency() { + // 1. Spawn server binary with --test-mode --port 0 + let server_bin = env!("CARGO_BIN_EXE_settled-reach-server"); + let mut child = Command::new(server_bin) + .args(["--test-mode", "--port", "0"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn server binary"); + + let stdout = child.stdout.take().expect("stdout not captured"); + let mut stdout_reader = BufReader::new(stdout); + + // 2. Parse LISTENING:{port} from stdout + let port = { + let deadline = Instant::now() + LISTEN_TIMEOUT; + let mut line = String::new(); + loop { + line.clear(); + match stdout_reader.read_line(&mut line) { + Ok(0) => panic!("server stdout closed before LISTENING signal"), + Ok(_) => { + let trimmed = line.trim(); + if let Some(port_str) = trimmed.strip_prefix("LISTENING:") { + break port_str + .parse::() + .unwrap_or_else(|e| panic!("invalid port '{}': {}", port_str, e)); + } + } + Err(e) => panic!("failed to read server stdout: {}", e), + } + assert!( + Instant::now() < deadline, + "timed out waiting for LISTENING signal" + ); + } + }; + + // 3. Connect via TCP + let addr = format!("127.0.0.1:{}", port); + let stream = TcpStream::connect(&addr) + .unwrap_or_else(|e| panic!("failed to connect to {}: {}", addr, e)); + stream + .set_read_timeout(Some(ROUND_TRIP_TIMEOUT)) + .expect("set read timeout"); + + let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); + let mut writer = BufWriter::new(stream); + + // TODO (#342, #555/#556): Wait for HandshakeMessage here before starting timing. + // When the server sends HandshakeMessage { protocol_version: 14 } as the first + // framed message, read and validate it. If version != PROTOCOL_VERSION, abort. + // The timing loop below starts after a successful handshake. + // + // For now, connect and proceed directly — the timing loop handles whatever + // the server sends as its first message. + + let make_input = |tick: u64| PlayerInput { + tick, + action: PlayerAction::MoveNorth, + }; + + let mut round_trip_ms: Vec = Vec::with_capacity(WARMUP_ROUNDS + MEASURE_ROUNDS); + + // 4. Warmup rounds (not timed) + for tick in 0..WARMUP_ROUNDS as u64 { + let payload = + rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); + write_framed(&mut writer, &payload).expect("send warmup input"); + let _ = read_framed(&mut reader) + .expect("read warmup snapshot") + .expect("server closed during warmup"); + } + + // 5. Timed measurement rounds + for tick in WARMUP_ROUNDS as u64..(WARMUP_ROUNDS + MEASURE_ROUNDS) as u64 { + let payload = + rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); + + let t_send = Instant::now(); + write_framed(&mut writer, &payload).expect("send timed input"); + let response = read_framed(&mut reader) + .expect("read timed snapshot") + .expect("server closed during measurement"); + let elapsed_ms = t_send.elapsed().as_secs_f64() * 1000.0; + + // Verify we received a valid snapshot (not just noise) + let _snapshot: ObserverSnapshot = + rmp_serde::from_slice(&response).expect("deserialize ObserverSnapshot"); + + round_trip_ms.push(elapsed_ms); + } + + // 6. Clean up + drop(reader); + drop(writer); + let exit_deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) => { + if Instant::now() > exit_deadline { + child.kill().ok(); + child.wait().ok(); + break; + } + std::thread::sleep(Duration::from_millis(50)); + } + Err(_) => { + child.kill().ok(); + break; + } + } + } + + // 7. Compute percentiles + let mut sorted = round_trip_ms.clone(); + sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); + + let p50 = percentile(&sorted, 0.50); + let p95 = percentile(&sorted, 0.95); + let p99 = percentile(&sorted, 0.99); + let passed = p99 <= THRESHOLD_MS; + + let result = serde_json::json!({ + "p50_ms": (p50 * 100.0).round() / 100.0, + "p95_ms": (p95 * 100.0).round() / 100.0, + "p99_ms": (p99 * 100.0).round() / 100.0, + "threshold_ms": THRESHOLD_MS, + "passed": passed, + "rounds": MEASURE_ROUNDS, + }); + + println!( + "IPC_BENCH_RESULT:{}", + serde_json::to_string(&result).unwrap() + ); + + // Fail the test if we exceed the latency budget + assert!( + passed, + "IPC latency budget exceeded: p99={:.2}ms > threshold={}ms", + p99, THRESHOLD_MS + ); +} From 01b354569b09bc31319f6f77ca9aa88efc10365c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:48:32 +0100 Subject: [PATCH 5/7] chore(config): allowlist cargo and test runner commands in settings Co-Authored-By: Claude Opus 4.6 --- .claude/settings.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.claude/settings.json b/.claude/settings.json index 16721c97e..7fdad419b 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -46,6 +46,14 @@ "Bash(tea *)", "Bash(tooling/tea-comment *)", + "Bash(cargo test *)", + "Bash(cargo test)", + "Bash(cargo build *)", + "Bash(cargo build)", + "Bash(cargo check *)", + "Bash(cargo check)", + "Bash(tests/run-*)", + "Bash(chmod *)", "Bash(ls *)", "Bash(find *)", From b6c4ecb3023d584438ce5c85878c4cb261a0e171 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 12:57:59 +0100 Subject: [PATCH 6/7] feat(ci): protocol version handshake client + IPC benchmark (#556, #342) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #556: HANDSHAKING state in sim_bridge.gd — decodes first framed message as HandshakeMessage, validates vs Protocol.PROTOCOL_VERSION, 5s timeout, handshake_complete/handshake_failed signals. #342: IPC benchmark now reads and validates HandshakeMessage before starting the timing loop. Co-Authored-By: Claude Opus 4.6 --- client/scripts/autoloads/sim_bridge.gd | 67 +++++++++++++++++++++++++- server/tests/ipc_bench.rs | 32 ++++++------ 2 files changed, 83 insertions(+), 16 deletions(-) diff --git a/client/scripts/autoloads/sim_bridge.gd b/client/scripts/autoloads/sim_bridge.gd index fa91fb3c3..b765cc62a 100644 --- a/client/scripts/autoloads/sim_bridge.gd +++ b/client/scripts/autoloads/sim_bridge.gd @@ -1,7 +1,7 @@ extends Node # Connection states -enum ConnectionState { DISCONNECTED, CONNECTING, CONNECTED, ERROR } +enum ConnectionState { DISCONNECTED, CONNECTING, HANDSHAKING, CONNECTED, ERROR } var state: ConnectionState = ConnectionState.DISCONNECTED var test_mode: bool = OS.get_environment("SR_LIVE") != "1" # SR_LIVE=1 connects to real server @@ -27,9 +27,15 @@ const CONNECT_RETRY_INTERVAL: float = 0.1 # Seconds between retry attempts var _connect_retries: int = 0 var _retry_timer: float = 0.0 +# Handshake state (#556) +const HANDSHAKE_TIMEOUT_USEC: int = 5_000_000 # 5 seconds +var _handshake_start_usec: int = 0 + # Signals signal connection_state_changed(old_state: ConnectionState, new_state: ConnectionState) signal snapshot_received(snapshot: Dictionary) +signal handshake_complete(protocol_version: int) +signal handshake_failed(reason: String) func _ready() -> void: if test_mode: @@ -121,7 +127,8 @@ func _process(delta: float) -> void: _bridge.poll() match _bridge.get_status(): StreamPeerTCP.STATUS_CONNECTED: - _set_state(ConnectionState.CONNECTED) + _handshake_start_usec = Time.get_ticks_usec() + _set_state(ConnectionState.HANDSHAKING) StreamPeerTCP.STATUS_CONNECTING: pass # Still connecting, wait StreamPeerTCP.STATUS_ERROR: @@ -134,6 +141,62 @@ func _process(delta: float) -> void: _bridge = null # Reset and retry return + # HANDSHAKING state: read first framed message, validate HandshakeMessage (#556) + if state == ConnectionState.HANDSHAKING: + if _bridge == null: + _set_state(ConnectionState.ERROR) + return + _bridge.poll() + + # Check connection dropped during handshake + var bridge_status := _bridge.get_status() + if bridge_status == StreamPeerTCP.STATUS_ERROR or bridge_status == StreamPeerTCP.STATUS_NONE: + var reason := "Connection dropped during handshake" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge = null + _set_state(ConnectionState.ERROR) + return + + # Check timeout + if Time.get_ticks_usec() - _handshake_start_usec > HANDSHAKE_TIMEOUT_USEC: + var reason := "Handshake timeout: no message received within 5 seconds" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + # Try to read first message + var msg := _bridge.poll_message() + if msg.is_empty(): + return # Not ready yet, continue polling + + # Decode HandshakeMessage: { "protocol_version": N } + var decoded: Variant = Messagepack.decode(msg) + if decoded.status != null or not (decoded.value is Dictionary) \ + or not decoded.value.has("protocol_version"): + var reason := "Handshake decode failed: malformed HandshakeMessage" + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + var server_version: int = decoded.value["protocol_version"] + if server_version != Protocol.PROTOCOL_VERSION: + var reason := "Protocol version mismatch: server=%d, client=%d" % [ + server_version, Protocol.PROTOCOL_VERSION] + push_error("SimBridge: %s" % reason) + handshake_failed.emit(reason) + _bridge.disconnect_from_server() + _set_state(ConnectionState.ERROR) + return + + handshake_complete.emit(server_version) + _set_state(ConnectionState.CONNECTED) + return + if _bridge == null: return diff --git a/server/tests/ipc_bench.rs b/server/tests/ipc_bench.rs index dc8776f6e..4f9ffee6d 100644 --- a/server/tests/ipc_bench.rs +++ b/server/tests/ipc_bench.rs @@ -10,9 +10,6 @@ //! //! Output: IPC_BENCH_RESULT:{json} on a single line for tooling to parse. //! -//! BLOCKED (#342): Handshake step is stubbed pending #555 (server) + #556 (client). -//! The test currently skips the HandshakeMessage exchange and starts timing -//! immediately after TCP connection is established. //! //! Spec references: D-020 (subprocess IPC, 5ms budget), D-030 (Layer 3) @@ -97,13 +94,20 @@ fn ipc_round_trip_latency() { let mut reader = BufReader::new(stream.try_clone().expect("clone stream")); let mut writer = BufWriter::new(stream); - // TODO (#342, #555/#556): Wait for HandshakeMessage here before starting timing. - // When the server sends HandshakeMessage { protocol_version: 14 } as the first - // framed message, read and validate it. If version != PROTOCOL_VERSION, abort. - // The timing loop below starts after a successful handshake. - // - // For now, connect and proceed directly — the timing loop handles whatever - // the server sends as its first message. + // 4. Handshake: read and validate HandshakeMessage before timing (#555/#556). + // Server sends HandshakeMessage { protocol_version } as the very first framed message. + let handshake_bytes = read_framed(&mut reader) + .expect("read handshake") + .expect("server closed before sending HandshakeMessage"); + let handshake: HandshakeMessage = + rmp_serde::from_slice(&handshake_bytes).expect("deserialize HandshakeMessage"); + assert_eq!( + handshake.protocol_version, + PROTOCOL_VERSION, + "handshake version mismatch: server={}, client={}", + handshake.protocol_version, + PROTOCOL_VERSION + ); let make_input = |tick: u64| PlayerInput { tick, @@ -112,7 +116,7 @@ fn ipc_round_trip_latency() { let mut round_trip_ms: Vec = Vec::with_capacity(WARMUP_ROUNDS + MEASURE_ROUNDS); - // 4. Warmup rounds (not timed) + // 5. Warmup rounds (not timed) for tick in 0..WARMUP_ROUNDS as u64 { let payload = rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); @@ -122,7 +126,7 @@ fn ipc_round_trip_latency() { .expect("server closed during warmup"); } - // 5. Timed measurement rounds + // 6. Timed measurement rounds for tick in WARMUP_ROUNDS as u64..(WARMUP_ROUNDS + MEASURE_ROUNDS) as u64 { let payload = rmp_serde::to_vec_named(&vec![make_input(tick)]).expect("serialize PlayerInput"); @@ -141,7 +145,7 @@ fn ipc_round_trip_latency() { round_trip_ms.push(elapsed_ms); } - // 6. Clean up + // 7. Clean up drop(reader); drop(writer); let exit_deadline = Instant::now() + Duration::from_secs(5); @@ -163,7 +167,7 @@ fn ipc_round_trip_latency() { } } - // 7. Compute percentiles + // 8. Compute percentiles let mut sorted = round_trip_ms.clone(); sorted.sort_by(|a, b| a.partial_cmp(b).unwrap()); From fcd2fd3cf7f0127fb8718e4272ee7ff721c8aa14 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Wed, 25 Feb 2026 13:02:02 +0100 Subject: [PATCH 7/7] fix(skills): pr-review must run from main worktree, no workarounds Co-Authored-By: Claude Opus 4.6 --- .claude/skills/pr-review/SKILL.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/skills/pr-review/SKILL.md b/.claude/skills/pr-review/SKILL.md index 8a9241b63..1840c4850 100644 --- a/.claude/skills/pr-review/SKILL.md +++ b/.claude/skills/pr-review/SKILL.md @@ -16,15 +16,17 @@ on the branch type. All reviewers must approve for a clean review. ## Workflow -### 0. Branch guard — MUST be on `main` +### 0. Branch guard — MUST be run by a Claude instance in the `main` worktree ```bash git branch --show-current ``` If the current branch is **not `main`**, stop immediately and tell the user: -"PR reviews must be run from the `main` worktree. Switch to `main` first." -Do NOT proceed with the review from a team branch. +"PR reviews must be run by a Claude instance in the `main` worktree." +Do NOT proceed with the review. Do NOT work around this by reading files +from another worktree — the review agent itself must be running in main. +Stop and wait for the user to invoke `/pr-review` from main. ### 1. Determine the branch to review