Skip to content

Commit 3664017

Browse files
starknet_os_runner: add rpc_records infrastructure for recording and replaying RPC interactions
1 parent b2650f6 commit 3664017

File tree

4 files changed

+209
-200
lines changed

4 files changed

+209
-200
lines changed

crates/starknet_os_runner/src/running.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,14 @@ pub mod virtual_block_executor;
1515
#[cfg(test)]
1616
mod classes_provider_test;
1717
#[cfg(test)]
18+
pub mod rpc_records;
19+
#[cfg(test)]
20+
mod rpc_records_test;
21+
#[cfg(test)]
1822
mod runner_test;
1923
#[cfg(test)]
2024
mod storage_proofs_test;
2125
#[cfg(test)]
22-
pub mod test_fixtures;
23-
#[cfg(test)]
2426
pub mod test_utils;
2527
#[cfg(test)]
2628
mod virtual_block_executor_test;
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
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_RECORDS=1`): Tests run against a real RPC node through a
10+
//! recording proxy that saves all request/response pairs to JSON files.
11+
//!
12+
//! - **Replay mode** (record 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, PathBuf};
19+
20+
use apollo_infra_utils::compile_time_cargo_manifest_dir;
21+
use serde::{Deserialize, Serialize};
22+
23+
/// A recorded JSON-RPC request-response pair.
24+
#[derive(Debug, Clone, Serialize, Deserialize)]
25+
pub struct RpcInteraction {
26+
/// The JSON-RPC method name (e.g., "starknet_getStorageAt").
27+
pub method: String,
28+
/// The JSON-RPC parameters.
29+
pub params: serde_json::Value,
30+
/// The full JSON-RPC response body.
31+
pub response: serde_json::Value,
32+
}
33+
34+
/// Collection of recorded RPC interactions for a test.
35+
#[derive(Debug, Clone, Serialize, Deserialize)]
36+
pub struct RpcRecords {
37+
/// All recorded interactions, in order.
38+
pub interactions: Vec<RpcInteraction>,
39+
}
40+
41+
impl RpcRecords {
42+
/// Loads recorded RPC interactions from a JSON file.
43+
pub fn load(path: &Path) -> Self {
44+
let content = fs::read_to_string(path)
45+
.unwrap_or_else(|e| panic!("Failed to read records from {path:?}: {e}"));
46+
serde_json::from_str(&content)
47+
.unwrap_or_else(|e| panic!("Failed to parse records from {path:?}: {e}"))
48+
}
49+
50+
/// Saves recorded RPC interactions to a JSON file.
51+
pub fn save(&self, path: &Path) {
52+
let dir = path.parent().expect("Invalid record path");
53+
fs::create_dir_all(dir)
54+
.unwrap_or_else(|e| panic!("Failed to create directory {dir:?}: {e}"));
55+
let content =
56+
serde_json::to_string_pretty(self).expect("Failed to serialize RPC records");
57+
fs::write(path, content)
58+
.unwrap_or_else(|e| panic!("Failed to write records to {path:?}: {e}"));
59+
}
60+
}
61+
62+
/// Creates a mockito server pre-configured with all recorded RPC interactions.
63+
///
64+
/// The server matches JSON-RPC requests by their `method` and `params` fields,
65+
/// returning the recorded response for each matching request.
66+
/// The `id` and `jsonrpc` version fields are ignored during matching so that
67+
/// the mock works with both `RpcStateReader` and `JsonRpcClient` regardless
68+
/// of their internal request formatting.
69+
pub async fn setup_mock_rpc_server(records: &RpcRecords) -> mockito::ServerGuard {
70+
let mut server = mockito::Server::new_async().await;
71+
for interaction in &records.interactions {
72+
let request_matcher = serde_json::json!({
73+
"method": interaction.method,
74+
"params": interaction.params,
75+
});
76+
server
77+
.mock("POST", "/")
78+
.match_body(mockito::Matcher::PartialJson(request_matcher))
79+
.with_status(200)
80+
.with_header("content-type", "application/json")
81+
.with_body(serde_json::to_string(&interaction.response).unwrap())
82+
.create_async()
83+
.await;
84+
}
85+
server
86+
}
87+
88+
// ================================================================================================
89+
// Path helpers
90+
// ================================================================================================
91+
92+
/// Returns the path to the RPC records directory for the starknet_os_runner crate.
93+
pub fn records_dir() -> PathBuf {
94+
PathBuf::from(compile_time_cargo_manifest_dir!()).join("resources").join("fixtures")
95+
}
96+
97+
/// Returns the path to a specific test's record file.
98+
pub fn record_path(test_name: &str) -> PathBuf {
99+
records_dir().join(format!("{test_name}.json"))
100+
}
101+
102+
/// Returns true if a record file exists for the given test.
103+
pub fn records_exist(test_name: &str) -> bool {
104+
record_path(test_name).exists()
105+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
//! Tests for the RPC records infrastructure.
2+
3+
use crate::running::rpc_records::{
4+
setup_mock_rpc_server,
5+
RpcInteraction,
6+
RpcRecords,
7+
};
8+
9+
#[test]
10+
fn test_rpc_records_round_trip_serialization() {
11+
let records = RpcRecords {
12+
interactions: vec![
13+
RpcInteraction {
14+
method: "starknet_getStorageAt".to_string(),
15+
params: serde_json::json!({
16+
"block_id": {"block_number": 100},
17+
"contract_address": "0x1",
18+
"key": "0x2"
19+
}),
20+
response: serde_json::json!({
21+
"jsonrpc": "2.0",
22+
"id": 0,
23+
"result": "0x999"
24+
}),
25+
},
26+
RpcInteraction {
27+
method: "starknet_blockNumber".to_string(),
28+
params: serde_json::json!([]),
29+
response: serde_json::json!({
30+
"jsonrpc": "2.0",
31+
"id": 1,
32+
"result": 100
33+
}),
34+
},
35+
],
36+
};
37+
38+
let serialized = serde_json::to_string_pretty(&records).unwrap();
39+
let deserialized: RpcRecords = serde_json::from_str(&serialized).unwrap();
40+
41+
assert_eq!(records.interactions.len(), deserialized.interactions.len());
42+
assert_eq!(records.interactions[0].method, deserialized.interactions[0].method);
43+
assert_eq!(records.interactions[0].params, deserialized.interactions[0].params);
44+
assert_eq!(records.interactions[0].response, deserialized.interactions[0].response);
45+
}
46+
47+
#[test]
48+
fn test_rpc_records_save_and_load() {
49+
let records = RpcRecords {
50+
interactions: vec![RpcInteraction {
51+
method: "starknet_getNonce".to_string(),
52+
params: serde_json::json!({"block_id": "latest", "contract_address": "0x1"}),
53+
response: serde_json::json!({"jsonrpc": "2.0", "id": 0, "result": "0x0"}),
54+
}],
55+
};
56+
57+
let temp_dir = tempfile::tempdir().unwrap();
58+
let path = temp_dir.path().join("test_record.json");
59+
60+
records.save(&path);
61+
let loaded = RpcRecords::load(&path);
62+
63+
assert_eq!(loaded.interactions.len(), 1);
64+
assert_eq!(loaded.interactions[0].method, "starknet_getNonce");
65+
}
66+
67+
#[tokio::test]
68+
async fn test_mock_server_matches_rpc_request() {
69+
let records = RpcRecords {
70+
interactions: vec![RpcInteraction {
71+
method: "starknet_blockNumber".to_string(),
72+
params: serde_json::json!([]),
73+
response: serde_json::json!({
74+
"jsonrpc": "2.0",
75+
"id": 0,
76+
"result": 42
77+
}),
78+
}],
79+
};
80+
81+
let server = setup_mock_rpc_server(&records).await;
82+
83+
// Send a JSON-RPC request with different id/jsonrpc version (should still match).
84+
let client = reqwest::Client::new();
85+
let response = client
86+
.post(server.url())
87+
.json(&serde_json::json!({
88+
"jsonrpc": "2.0",
89+
"id": 99,
90+
"method": "starknet_blockNumber",
91+
"params": []
92+
}))
93+
.send()
94+
.await
95+
.unwrap();
96+
97+
assert_eq!(response.status(), 200);
98+
let body: serde_json::Value = response.json().await.unwrap();
99+
assert_eq!(body["result"], 42);
100+
}

0 commit comments

Comments
 (0)