Merge remote-tracking branch 'origin/ci'
This commit is contained in:
@@ -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 *)",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -21,9 +21,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:
|
||||
@@ -159,7 +165,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:
|
||||
@@ -172,6 +179,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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
��
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲaction→Interactげtarget_entity_idc、verb、Talk
|
||||
@@ -0,0 +1 @@
|
||||
うtickヲactionゥMoveNorth
|
||||
Binary file not shown.
Binary file not shown.
@@ -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"
|
||||
)
|
||||
+19
-3
@@ -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 <name>` 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
|
||||
|
||||
@@ -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())
|
||||
@@ -405,13 +411,7 @@ pub fn compute_observer_snapshot(
|
||||
sound_events,
|
||||
rng_seed: sim_rng.as_deref().map(|r| r.seed()),
|
||||
poi_list,
|
||||
examine_result: examine_result.map(|e| {
|
||||
crate::bridge::types::ExamineResultWire {
|
||||
entity_id: e.target_entity_id,
|
||||
text: e.text,
|
||||
confidence: crate::bridge::types::KnowledgeConfidence::Direct,
|
||||
}
|
||||
}),
|
||||
examine_result,
|
||||
player_knowledge,
|
||||
save_result,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -287,6 +288,151 @@ 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,
|
||||
}),
|
||||
save_result: None,
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
//! 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.
|
||||
//!
|
||||
//!
|
||||
//! 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::<u16>()
|
||||
.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);
|
||||
|
||||
// 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,
|
||||
action: PlayerAction::MoveNorth,
|
||||
};
|
||||
|
||||
let mut round_trip_ms: Vec<f64> = Vec::with_capacity(WARMUP_ROUNDS + MEASURE_ROUNDS);
|
||||
|
||||
// 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");
|
||||
write_framed(&mut writer, &payload).expect("send warmup input");
|
||||
let _ = read_framed(&mut reader)
|
||||
.expect("read warmup snapshot")
|
||||
.expect("server closed during warmup");
|
||||
}
|
||||
|
||||
// 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");
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 7. 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 8. 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
|
||||
);
|
||||
}
|
||||
@@ -180,13 +180,15 @@ fn all_fixtures_deserialize() {
|
||||
} else if name.starts_with("input_batch") {
|
||||
rmp_serde::from_slice::<Vec<PlayerInput>>(&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::<PlayerInput>(&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::<u64>(&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",
|
||||
@@ -1400,7 +1402,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,
|
||||
@@ -1440,7 +1442,7 @@ fn serde_default_fields_fill_in_when_missing_from_wire() {
|
||||
serde_json::from_value(minimal_json).expect("minimal JSON must deserialize");
|
||||
|
||||
// Version matches what was in the wire
|
||||
assert_eq!(decoded.version, 14);
|
||||
assert_eq!(decoded.version, 13);
|
||||
assert_eq!(decoded.tick, 42);
|
||||
assert_eq!(decoded.entities.len(), 1);
|
||||
|
||||
@@ -1648,3 +1650,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<u8> {
|
||||
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::<ObserverSnapshot>(&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::<PlayerInput>(&bytes);
|
||||
assert!(
|
||||
result2.is_err(),
|
||||
"malformed fixture should fail to deserialize as PlayerInput"
|
||||
);
|
||||
}
|
||||
|
||||
Executable
+77
@@ -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
|
||||
Executable
+88
@@ -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
|
||||
Executable
+59
@@ -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
|
||||
Executable
+98
@@ -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
|
||||
Executable
+72
@@ -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
|
||||
Executable
+50
@@ -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
|
||||
Executable
+54
@@ -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
|
||||
Reference in New Issue
Block a user