Skip to content

Commit b2650f6

Browse files
starknet_os_runner: add test fixtures infrastructure for RPC recording and replay
1 parent f30d2bc commit b2650f6

File tree

4 files changed

+206
-0
lines changed

4 files changed

+206
-0
lines changed

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/starknet_os_runner/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ url.workspace = true
4545
[dev-dependencies]
4646
blockifier = { workspace = true, features = ["testing"] }
4747
blockifier_test_utils.workspace = true
48+
expect-test.workspace = true
49+
mockito.workspace = true
50+
reqwest.workspace = true
4851
rstest.workspace = true
4952
serde = { workspace = true, features = ["derive"] }
5053

crates/starknet_os_runner/src/running.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ mod runner_test;
1919
#[cfg(test)]
2020
mod storage_proofs_test;
2121
#[cfg(test)]
22+
pub mod test_fixtures;
23+
#[cfg(test)]
2224
pub mod test_utils;
2325
#[cfg(test)]
2426
mod virtual_block_executor_test;
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
//! Utilities for recording and replaying RPC responses in tests.
2+
//!
3+
//! This module provides infrastructure for running integration tests offline
4+
//! by recording JSON-RPC interactions with real nodes and replaying them
5+
//! through a mock HTTP server.
6+
//!
7+
//! ## Modes
8+
//!
9+
//! - **Recording mode** (`RECORD_RPC_FIXTURES=1`): Tests run against a real RPC node
10+
//! through a recording proxy that saves all request/response pairs to JSON fixture files.
11+
//!
12+
//! - **Replay mode** (fixture files present): Tests start a mock HTTP server that serves
13+
//! pre-recorded responses, enabling fully offline execution (used in CI).
14+
//!
15+
//! - **Live mode** (default): Tests use a real RPC node directly (existing behavior).
16+
17+
use std::fs;
18+
use std::path::Path;
19+
20+
use serde::{Deserialize, Serialize};
21+
22+
/// A recorded JSON-RPC request-response pair.
23+
#[derive(Debug, Clone, Serialize, Deserialize)]
24+
pub struct RpcInteraction {
25+
/// The JSON-RPC method name (e.g., "starknet_getStorageAt").
26+
pub method: String,
27+
/// The JSON-RPC parameters.
28+
pub params: serde_json::Value,
29+
/// The full JSON-RPC response body.
30+
pub response: serde_json::Value,
31+
}
32+
33+
/// Collection of recorded RPC interactions for a test.
34+
#[derive(Debug, Clone, Serialize, Deserialize)]
35+
pub struct TestFixtures {
36+
/// All recorded interactions, in order.
37+
pub interactions: Vec<RpcInteraction>,
38+
}
39+
40+
impl TestFixtures {
41+
/// Loads test fixtures from a JSON file.
42+
pub fn load(path: &str) -> Self {
43+
let content = fs::read_to_string(path)
44+
.unwrap_or_else(|e| panic!("Failed to read fixtures from {path}: {e}"));
45+
serde_json::from_str(&content)
46+
.unwrap_or_else(|e| panic!("Failed to parse fixtures from {path}: {e}"))
47+
}
48+
49+
/// Saves test fixtures to a JSON file.
50+
pub fn save(&self, path: &str) {
51+
let dir = Path::new(path).parent().expect("Invalid fixture path");
52+
fs::create_dir_all(dir)
53+
.unwrap_or_else(|e| panic!("Failed to create directory {dir:?}: {e}"));
54+
let content = serde_json::to_string_pretty(self).expect("Failed to serialize fixtures");
55+
fs::write(path, content)
56+
.unwrap_or_else(|e| panic!("Failed to write fixtures to {path}: {e}"));
57+
}
58+
}
59+
60+
/// Creates a mockito server pre-configured with all recorded RPC interactions.
61+
///
62+
/// The server matches JSON-RPC requests by their `method` and `params` fields,
63+
/// returning the recorded response for each matching request.
64+
/// The `id` and `jsonrpc` version fields are ignored during matching so that
65+
/// the mock works with both `RpcStateReader` and `JsonRpcClient` regardless
66+
/// of their internal request formatting.
67+
pub async fn setup_mock_rpc_server(fixtures: &TestFixtures) -> mockito::ServerGuard {
68+
let mut server = mockito::Server::new_async().await;
69+
for interaction in &fixtures.interactions {
70+
let request_matcher = serde_json::json!({
71+
"method": interaction.method,
72+
"params": interaction.params,
73+
});
74+
server
75+
.mock("POST", "/")
76+
.match_body(mockito::Matcher::PartialJson(request_matcher))
77+
.with_status(200)
78+
.with_header("content-type", "application/json")
79+
.with_body(serde_json::to_string(&interaction.response).unwrap())
80+
.create_async()
81+
.await;
82+
}
83+
server
84+
}
85+
86+
/// Returns the path to the fixtures directory for the starknet_os_runner crate.
87+
pub fn fixtures_dir() -> String {
88+
format!("{}/resources/fixtures", env!("CARGO_MANIFEST_DIR"))
89+
}
90+
91+
/// Returns the path to a specific test's fixture file.
92+
pub fn fixture_path(test_name: &str) -> String {
93+
format!("{}/{test_name}.json", fixtures_dir())
94+
}
95+
96+
/// Returns true if fixture files exist for the given test.
97+
pub fn fixtures_exist(test_name: &str) -> bool {
98+
Path::new(&fixture_path(test_name)).exists()
99+
}
100+
101+
#[cfg(test)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn test_fixtures_round_trip_serialization() {
107+
let fixtures = TestFixtures {
108+
interactions: vec![
109+
RpcInteraction {
110+
method: "starknet_getStorageAt".to_string(),
111+
params: serde_json::json!({
112+
"block_id": {"block_number": 100},
113+
"contract_address": "0x1",
114+
"key": "0x2"
115+
}),
116+
response: serde_json::json!({
117+
"jsonrpc": "2.0",
118+
"id": 0,
119+
"result": "0x999"
120+
}),
121+
},
122+
RpcInteraction {
123+
method: "starknet_blockNumber".to_string(),
124+
params: serde_json::json!([]),
125+
response: serde_json::json!({
126+
"jsonrpc": "2.0",
127+
"id": 1,
128+
"result": 100
129+
}),
130+
},
131+
],
132+
};
133+
134+
let serialized = serde_json::to_string_pretty(&fixtures).unwrap();
135+
let deserialized: TestFixtures = serde_json::from_str(&serialized).unwrap();
136+
137+
assert_eq!(fixtures.interactions.len(), deserialized.interactions.len());
138+
assert_eq!(fixtures.interactions[0].method, deserialized.interactions[0].method);
139+
assert_eq!(fixtures.interactions[0].params, deserialized.interactions[0].params);
140+
assert_eq!(fixtures.interactions[0].response, deserialized.interactions[0].response);
141+
}
142+
143+
#[test]
144+
fn test_fixtures_save_and_load() {
145+
let fixtures = TestFixtures {
146+
interactions: vec![RpcInteraction {
147+
method: "starknet_getNonce".to_string(),
148+
params: serde_json::json!({"block_id": "latest", "contract_address": "0x1"}),
149+
response: serde_json::json!({"jsonrpc": "2.0", "id": 0, "result": "0x0"}),
150+
}],
151+
};
152+
153+
let temp_dir = tempfile::tempdir().unwrap();
154+
let path = temp_dir.path().join("test_fixture.json");
155+
let path_str = path.to_str().unwrap();
156+
157+
fixtures.save(path_str);
158+
let loaded = TestFixtures::load(path_str);
159+
160+
assert_eq!(loaded.interactions.len(), 1);
161+
assert_eq!(loaded.interactions[0].method, "starknet_getNonce");
162+
}
163+
164+
#[tokio::test]
165+
async fn test_mock_server_matches_rpc_request() {
166+
let fixtures = TestFixtures {
167+
interactions: vec![RpcInteraction {
168+
method: "starknet_blockNumber".to_string(),
169+
params: serde_json::json!([]),
170+
response: serde_json::json!({
171+
"jsonrpc": "2.0",
172+
"id": 0,
173+
"result": 42
174+
}),
175+
}],
176+
};
177+
178+
let server = setup_mock_rpc_server(&fixtures).await;
179+
180+
// Send a JSON-RPC request with different id/jsonrpc version (should still match).
181+
let client = reqwest::Client::new();
182+
let response = client
183+
.post(server.url())
184+
.json(&serde_json::json!({
185+
"jsonrpc": "2.0",
186+
"id": 99,
187+
"method": "starknet_blockNumber",
188+
"params": []
189+
}))
190+
.send()
191+
.await
192+
.unwrap();
193+
194+
assert_eq!(response.status(), 200);
195+
let body: serde_json::Value = response.json().await.unwrap();
196+
assert_eq!(body["result"], 42);
197+
}
198+
}

0 commit comments

Comments
 (0)