rojo/tests/rojo_test/serve_util.rs
Joe 67537070db Refactor path suppression handling in ChangeProcessor and ApiService
- Updated the `suppressed_paths` structure in `ChangeProcessor` and `ApiService` to store counts for both remove and create/write events, enhancing the accuracy of event suppression.
- Refactored methods for suppressing and unsuppressing paths to accommodate the new structure, ensuring that the system correctly manages VFS events based on the type of operation.
- Improved documentation and comments to clarify the purpose and behavior of the suppression logic, contributing to better maintainability and understanding of the code.
2026-02-06 13:01:07 -06:00

358 lines
13 KiB
Rust

use std::{
fmt::Write as _,
fs,
io::Read as _,
path::{Path, PathBuf},
process::{Command, Stdio},
thread,
time::Duration,
};
use hyper_tungstenite::tungstenite::{connect, Message};
use rbx_dom_weak::types::Ref;
use serde::Deserialize;
use tempfile::{tempdir, TempDir};
use librojo::web_api::{
ReadResponse, SerializeResponse, ServerInfoResponse, SocketPacket, SocketPacketType,
};
use rojo_insta_ext::RedactionMap;
use crate::rojo_test::io_util::{
copy_recursive, get_working_dir_path, KillOnDrop, ROJO_PATH, SERVE_TESTS_PATH,
};
/// Convenience method to run a `rojo serve` test.
///
/// Test projects should be defined in the `serve-tests` folder; their filename
/// should be given as the first parameter.
///
/// The passed in callback is where the actual test body should go. Setup and
/// cleanup happens automatically.
pub fn run_serve_test(test_name: &str, callback: impl FnOnce(TestServeSession, RedactionMap)) {
let _ = env_logger::try_init();
let mut redactions = RedactionMap::default();
let mut session = TestServeSession::new(test_name);
let info = session.wait_to_come_online();
// On macOS, the FSEvents-backed file watcher (via notify-debouncer-full)
// may not have fully populated its internal cache by the time the HTTP
// server reports as online. Without a warm cache, the debouncer cannot
// correctly classify events (e.g., it may miss a Remove if the file was
// not yet in the cache). Give it time to complete its initial scan.
#[cfg(target_os = "macos")]
thread::sleep(Duration::from_secs(1));
redactions.intern(info.session_id);
redactions.intern(info.root_instance_id);
let mut settings = insta::Settings::new();
let snapshot_path = Path::new(SERVE_TESTS_PATH)
.parent()
.unwrap()
.join("serve-test-snapshots");
settings.set_snapshot_path(snapshot_path);
settings.set_sort_maps(true);
settings.add_redaction(".serverVersion", "[server-version]");
settings.bind(move || callback(session, redactions));
}
/// Represents a running Rojo serve session running in a temporary directory.
pub struct TestServeSession {
// Drop order is important here: we want the process to be killed before the
// directory it's operating on is destroyed.
rojo_process: KillOnDrop,
_dir: TempDir,
port: usize,
project_path: PathBuf,
}
impl TestServeSession {
pub fn new(name: &str) -> Self {
let working_dir = get_working_dir_path();
let source_path = Path::new(SERVE_TESTS_PATH).join(name);
let dir = tempdir().expect("Couldn't create temporary directory");
let project_path = dir
.path()
.canonicalize()
.expect("Couldn't canonicalize temporary directory path")
.join(name);
let source_is_file = fs::metadata(&source_path).unwrap().is_file();
if source_is_file {
fs::copy(&source_path, &project_path).expect("couldn't copy project file");
} else {
fs::create_dir(&project_path).expect("Couldn't create temporary project subdirectory");
copy_recursive(&source_path, &project_path)
.expect("Couldn't copy project to temporary directory");
};
// This is an ugly workaround for FSEvents sometimes reporting events
// for the above copy operations, similar to this Stack Overflow question:
// https://stackoverflow.com/questions/47679298/howto-avoid-receiving-old-events-in-fseventstream-callback-fsevents-framework-o
// 500ms gives FSEvents time to flush any stale events from the copy.
#[cfg(target_os = "macos")]
std::thread::sleep(Duration::from_millis(500));
let port = get_port_number();
let port_string = port.to_string();
let rojo_process = Command::new(ROJO_PATH)
.args([
"serve",
project_path.to_str().unwrap(),
"--port",
port_string.as_str(),
])
.current_dir(working_dir)
.stderr(Stdio::piped())
.spawn()
.expect("Couldn't start Rojo");
TestServeSession {
rojo_process: KillOnDrop(rojo_process),
_dir: dir,
port,
project_path,
}
}
pub fn path(&self) -> &Path {
&self.project_path
}
/// Waits for the `rojo serve` server to come online with expontential
/// backoff.
pub fn wait_to_come_online(&mut self) -> ServerInfoResponse {
const BASE_DURATION_MS: f32 = 30.0;
const EXP_BACKOFF_FACTOR: f32 = 1.3;
const MAX_TRIES: u32 = 5;
for i in 1..=MAX_TRIES {
match self.rojo_process.0.try_wait() {
Ok(Some(status)) => {
let mut stderr_output = String::new();
if let Some(mut stderr) = self.rojo_process.0.stderr.take() {
let _ = stderr.read_to_string(&mut stderr_output);
}
panic!(
"Rojo process exited with status {}\nstderr:\n{}",
status, stderr_output
);
}
Ok(None) => { /* The process is still running, as expected */ }
Err(err) => panic!("Failed to wait on Rojo process: {}", err),
}
let info = match self.get_api_rojo() {
Ok(info) => info,
Err(err) => {
let retry_time_ms = BASE_DURATION_MS * (i as f32).powf(EXP_BACKOFF_FACTOR);
let retry_time = Duration::from_millis(retry_time_ms as u64);
log::info!("Server error, retrying in {:?}: {}", retry_time, err);
thread::sleep(retry_time);
continue;
}
};
log::info!("Got session info: {:?}", info);
return info;
}
panic!("Rojo server did not respond after {} tries.", MAX_TRIES);
}
pub fn get_api_rojo(&self) -> Result<ServerInfoResponse, reqwest::Error> {
let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(url)?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
pub fn get_api_socket_packet(
&self,
packet_type: SocketPacketType,
cursor: u32,
) -> Result<SocketPacket<'static>, Box<dyn std::error::Error>> {
self.recv_socket_packet(packet_type, cursor, || {})
}
/// Start listening on the WebSocket, then run the provided action (e.g. a
/// file modification), and wait for the expected packet.
///
/// This avoids race conditions where the file watcher hasn't processed a
/// change before the WebSocket connects: by connecting first, the listener
/// is guaranteed to be in place when the change is detected.
pub fn recv_socket_packet(
&self,
packet_type: SocketPacketType,
cursor: u32,
action: impl FnOnce(),
) -> Result<SocketPacket<'static>, Box<dyn std::error::Error>> {
let url = format!("ws://localhost:{}/api/socket/{}", self.port, cursor);
let (mut socket, _response) = connect(url)?;
// Set a read timeout on the underlying TCP stream to prevent blocking forever.
// Without this, socket.read() blocks indefinitely if no data arrives.
let timeout = Duration::from_secs(10);
if let hyper_tungstenite::tungstenite::stream::MaybeTlsStream::Plain(ref stream) =
socket.get_ref()
{
stream.set_read_timeout(Some(Duration::from_millis(100)))?;
}
// Now that the WebSocket is connected and listening, perform the action
// that should trigger the change (e.g. writing/deleting a file).
action();
let start = std::time::Instant::now();
loop {
if start.elapsed() > timeout {
return Err("Timeout waiting for packet from WebSocket".into());
}
match socket.read() {
Ok(Message::Binary(binary)) => {
let packet: SocketPacket = deserialize_msgpack(&binary)?;
if packet.packet_type != packet_type {
continue;
}
// Close the WebSocket connection now that we got what we were waiting for
let _ = socket.close(None);
return Ok(packet);
}
Ok(Message::Close(_)) => {
return Err("WebSocket closed before receiving messages".into());
}
Ok(_) => {
// Ignore other message types (ping, pong, text)
continue;
}
Err(hyper_tungstenite::tungstenite::Error::Io(e))
if e.kind() == std::io::ErrorKind::WouldBlock
|| e.kind() == std::io::ErrorKind::TimedOut
|| e.kind() == std::io::ErrorKind::Interrupted =>
{
// No data available yet, read timed out, or interrupted by signal - try again
continue;
}
Err(e) => {
return Err(e.into());
}
}
}
}
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> {
let mut id_list = String::with_capacity(ids.len() * 33);
for id in ids {
write!(id_list, "{id},").unwrap();
}
id_list.pop();
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list);
let body = reqwest::blocking::get(url)?.bytes()?;
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
}
/// Post to /api/write to simulate plugin syncback operations.
/// Uses the library's WriteRequest type for proper serialization.
/// Uses human-readable msgpack format to match server expectations.
pub fn post_api_write(
&self,
request: &librojo::web_api::WriteRequest,
) -> Result<(), reqwest::Error> {
use serde::Serialize;
let url = format!("http://localhost:{}/api/write", self.port);
// Serialize with human-readable mode to match server expectations
let mut body = Vec::new();
let mut serializer = rmp_serde::Serializer::new(&mut body)
.with_human_readable()
.with_struct_map();
request
.serialize(&mut serializer)
.expect("Failed to serialize WriteRequest");
let client = reqwest::blocking::Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("Failed to build reqwest client");
let response = client.post(url).body(body).send()?;
if !response.status().is_success() {
panic!(
"Write request failed with status {}: {}",
response.status(),
response.text().unwrap_or_default()
);
}
Ok(())
}
}
fn deserialize_msgpack<'a, T: Deserialize<'a>>(
input: &'a [u8],
) -> Result<T, rmp_serde::decode::Error> {
let mut deserializer = rmp_serde::Deserializer::new(input).with_human_readable();
T::deserialize(&mut deserializer)
}
/// Obtain a free port by asking the OS to assign an ephemeral one.
///
/// Binds a temporary TcpListener to port 0 (the OS picks a free port),
/// reads back the assigned port, then drops the listener. The brief
/// TOCTOU window before Rojo rebinds is negligible on localhost.
fn get_port_number() -> usize {
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind ephemeral port for test");
let port = listener.local_addr().unwrap().port() as usize;
drop(listener);
port
}
/// Takes a SerializeResponse and creates an XML model out of the response.
///
/// Since the provided structure intentionally includes unredacted referents,
/// some post-processing is done to ensure they don't show up in the model.
pub fn serialize_to_xml_model(response: &SerializeResponse, redactions: &RedactionMap) -> String {
let mut dom = rbx_binary::from_reader(response.model_contents.as_slice()).unwrap();
// This makes me realize that maybe we need a `descendants_mut` iter.
let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect();
for referent in ref_list {
let inst = dom.get_by_ref_mut(referent).unwrap();
if let Some(id) = redactions.get_id_for_value(&inst.name) {
inst.name = format!("id-{id}");
}
}
let mut data = Vec::new();
rbx_xml::to_writer_default(&mut data, &dom, dom.root().children()).unwrap();
String::from_utf8(data).expect("rbx_xml should never produce invalid utf-8")
}