From 307ac81aecad9142b29489201ae5142b21858262 Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 18 Feb 2026 04:36:57 -0600 Subject: [PATCH] Implement live syncback feature and enhance API for service data transfer - Added a new `performSyncback` function in the `App` module to handle the synchronization of service data from the game to the server. - Introduced a `SyncbackConfirm` UI component to confirm syncback actions from the user. - Updated the API to include a new `/api/syncback` endpoint for handling syncback requests, validating protocol and server versions. - Enhanced the `SyncbackSignal` mechanism to facilitate communication between the server and the syncback process. - Added tests to ensure the functionality of the live syncback feature and its integration with the CLI. These changes improve the overall synchronization capabilities of the application, allowing for real-time updates and better project management. --- Cargo.toml | 2 +- plugin/src/App/StatusPages/NotConnected.lua | 52 +- plugin/src/App/init.lua | 213 ++++++- .../live_syncback/default.project.json5 | 18 + .../src/server/OldScript.server.luau | 1 + .../live_syncback/src/shared/OldModule.luau | 1 + .../src/workspace/OldPart.model.json5 | 6 + .../default.project.json5 | 16 + .../src/server/Placeholder.server.luau | 1 + .../src/shared/Placeholder.luau | 1 + src/cli/serve.rs | 215 ++++++- src/web/api.rs | 99 +++- src/web/interface.rs | 30 + src/web/mod.rs | 101 +++- tests/rojo_test/serve_util.rs | 215 +++++++ tests/tests/live_syncback.rs | 536 ++++++++++++++++++ tests/tests/mod.rs | 1 + 17 files changed, 1411 insertions(+), 97 deletions(-) create mode 100644 rojo-test/serve-tests/live_syncback/default.project.json5 create mode 100644 rojo-test/serve-tests/live_syncback/src/server/OldScript.server.luau create mode 100644 rojo-test/serve-tests/live_syncback/src/shared/OldModule.luau create mode 100644 rojo-test/serve-tests/live_syncback/src/workspace/OldPart.model.json5 create mode 100644 rojo-test/serve-tests/live_syncback_place_ids/default.project.json5 create mode 100644 rojo-test/serve-tests/live_syncback_place_ids/src/server/Placeholder.server.luau create mode 100644 rojo-test/serve-tests/live_syncback_place_ids/src/shared/Placeholder.luau create mode 100644 tests/tests/live_syncback.rs diff --git a/Cargo.toml b/Cargo.toml index 43245e01..f78cafd0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,7 +95,7 @@ strum = { version = "0.27", features = ["derive"] } toml = "0.9" termcolor = "1.4.1" thiserror = "2" -tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros"] } +tokio = { version = "1.49.0", features = ["rt", "rt-multi-thread", "macros", "sync"] } uuid = { version = "1.21.0", features = ["v4", "serde"] } clap = { version = "4", features = ["derive"] } profiling = "1.0.17" diff --git a/plugin/src/App/StatusPages/NotConnected.lua b/plugin/src/App/StatusPages/NotConnected.lua index af98380a..cdc4188a 100644 --- a/plugin/src/App/StatusPages/NotConnected.lua +++ b/plugin/src/App/StatusPages/NotConnected.lua @@ -112,29 +112,41 @@ function NotConnectedPage:render() BackgroundTransparency = 1, ZIndex = 2, }, { - Settings = e(TextButton, { - text = "Settings", - style = "Bordered", - transparency = self.props.transparency, - layoutOrder = 1, - onClick = self.props.onNavigateSettings, - }, { - Tip = e(Tooltip.Trigger, { - text = "View and modify plugin settings", - }), + Settings = e(TextButton, { + text = "Settings", + style = "Bordered", + transparency = self.props.transparency, + layoutOrder = 1, + onClick = self.props.onNavigateSettings, + }, { + Tip = e(Tooltip.Trigger, { + text = "View and modify plugin settings", }), + }), - Connect = e(TextButton, { - text = "Sync", - style = "Primary", - transparency = self.props.transparency, - layoutOrder = 2, - onClick = self.props.onConnect, - }, { - Tip = e(Tooltip.Trigger, { - text = "Connect to a Rojo sync server", - }), + Syncback = e(TextButton, { + text = "Syncback", + style = "Danger", + transparency = self.props.transparency, + layoutOrder = 2, + onClick = self.props.onSyncback, + }, { + Tip = e(Tooltip.Trigger, { + text = "Overwrite project files with current Studio state", }), + }), + + Connect = e(TextButton, { + text = "Sync", + style = "Primary", + transparency = self.props.transparency, + layoutOrder = 3, + onClick = self.props.onConnect, + }, { + Tip = e(Tooltip.Trigger, { + text = "Connect to a Rojo sync server", + }), + }), Layout = e("UIListLayout", { HorizontalAlignment = Enum.HorizontalAlignment.Right, diff --git a/plugin/src/App/init.lua b/plugin/src/App/init.lua index 57633fbf..f95e6f3d 100644 --- a/plugin/src/App/init.lua +++ b/plugin/src/App/init.lua @@ -1,8 +1,16 @@ local ChangeHistoryService = game:GetService("ChangeHistoryService") local Players = game:GetService("Players") +local SerializationService = game:GetService("SerializationService") local ServerStorage = game:GetService("ServerStorage") local RunService = game:GetService("RunService") +local SYNCBACK_SERVICES = { + "Lighting", "MaterialService", "ReplicatedFirst", "ReplicatedStorage", + "ServerScriptService", "ServerStorage", "SoundService", "StarterGui", + "StarterPack", "StarterPlayer", "Teams", "TextChatService", + "VoiceChatService", "Workspace", +} + local Rojo = script:FindFirstAncestor("Rojo") local Plugin = Rojo.Plugin local Packages = Rojo.Packages @@ -27,8 +35,11 @@ local ignorePlaceIds = require(Plugin.ignorePlaceIds) local timeUtil = require(Plugin.timeUtil) local Theme = require(script.Theme) +local Http = require(Packages.Http) + local Page = require(script.Page) local Notifications = require(script.Components.Notifications) +local TextButton = require(script.Components.TextButton) local Tooltip = require(script.Components.Tooltip) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioToolbar = require(script.Components.Studio.StudioToolbar) @@ -604,6 +615,68 @@ function App:useRunningConnectionInfo() self.setPort(port) end +function App:performSyncback() + self:setState({ showingSyncbackConfirm = false }) + + local host = self.host:getValue() + local port = self.port:getValue() + + if host == "" then + host = Config.defaultHost + end + if port == "" then + port = Config.defaultPort + end + + Promise.new(function(resolve, reject) + local services = {} + for _, className in SYNCBACK_SERVICES do + local ok, service = pcall(game.FindService, game, className) + if ok and service and #service:GetChildren() > 0 then + local buf = SerializationService:SerializeInstancesAsync(service:GetChildren()) + table.insert(services, { + className = className, + data = buf, + }) + end + end + + local url = ("http://%s:%s/api/syncback"):format(host, port) + local body = Http.msgpackEncode({ + protocolVersion = Config.protocolVersion, + serverVersion = Config.expectedServerVersionString, + placeId = game.PlaceId, + services = services, + }) + + Http.post(url, body) + :andThen(function(response) + if response.StatusCode >= 200 and response.StatusCode < 300 then + resolve() + else + reject( + `Syncback request failed ({response.StatusCode}): {response.Body or "unknown error"}` + ) + end + end) + :catch(reject) + end) + :andThen(function() + Log.info("Syncback data sent to server.") + self:addNotification({ + text = "Syncback data sent. Server is processing.", + timeout = 10, + }) + end) + :catch(function(err) + Log.warn("Syncback failed: " .. tostring(err)) + self:addNotification({ + text = "Syncback failed: " .. tostring(err), + timeout = 10, + }) + end) +end + function App:startSession() -- If a session is already in progress, tear it down first. -- In one-shot mode the sync lock is skipped, so this is the only guard @@ -1027,23 +1100,29 @@ function App:render() }, { Tooltips = e(Tooltip.Container, nil), - NotConnectedPage = createPageElement(AppStatus.NotConnected, { - host = self.host, - onHostChange = self.setHost, - port = self.port, - onPortChange = self.setPort, + NotConnectedPage = createPageElement(AppStatus.NotConnected, { + host = self.host, + onHostChange = self.setHost, + port = self.port, + onPortChange = self.setPort, - onConnect = function() - self:startSession() - end, + onConnect = function() + self:startSession() + end, - onNavigateSettings = function() - self.backPage = AppStatus.NotConnected - self:setState({ - appStatus = AppStatus.Settings, - }) - end, - }), + onSyncback = function() + self:setState({ + showingSyncbackConfirm = true, + }) + end, + + onNavigateSettings = function() + self.backPage = AppStatus.NotConnected + self:setState({ + appStatus = AppStatus.Settings, + }) + end, + }), ConfirmingPage = createPageElement(AppStatus.Confirming, { confirmData = self.state.confirmData, @@ -1117,22 +1196,100 @@ function App:render() }), }), - RojoNotifications = e("ScreenGui", { - ZIndexBehavior = Enum.ZIndexBehavior.Sibling, - ResetOnSpawn = false, - DisplayOrder = 100, - }, { - Notifications = e(Notifications, { - soundPlayer = self.props.soundPlayer, - notifications = self.state.notifications, - onClose = function(id) - self:closeNotification(id) - end, - }), + RojoNotifications = e("ScreenGui", { + ZIndexBehavior = Enum.ZIndexBehavior.Sibling, + ResetOnSpawn = false, + DisplayOrder = 100, + }, { + Notifications = e(Notifications, { + soundPlayer = self.props.soundPlayer, + notifications = self.state.notifications, + onClose = function(id) + self:closeNotification(id) + end, }), }), + }), - toggleAction = e(StudioPluginAction, { + SyncbackConfirm = e(StudioPluginGui, { + id = "Atlas_SyncbackConfirm", + title = "Full Syncback", + active = self.state.showingSyncbackConfirm == true, + isEphemeral = true, + + initDockState = Enum.InitialDockState.Float, + overridePreviousState = true, + floatingSize = Vector2.new(400, 150), + minimumSize = Vector2.new(300, 120), + + zIndexBehavior = Enum.ZIndexBehavior.Sibling, + + onClose = function() + self:setState({ showingSyncbackConfirm = false }) + end, + }, { + Content = Theme.with(function(theme) + return e("Frame", { + Size = UDim2.fromScale(1, 1), + BackgroundColor3 = theme.BackgroundColor, + BorderSizePixel = 0, + }, { + Padding = e("UIPadding", { + PaddingLeft = UDim.new(0, 16), + PaddingRight = UDim.new(0, 16), + PaddingTop = UDim.new(0, 16), + PaddingBottom = UDim.new(0, 16), + }), + Layout = e("UIListLayout", { + FillDirection = Enum.FillDirection.Vertical, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 16), + VerticalAlignment = Enum.VerticalAlignment.Center, + }), + Message = e("TextLabel", { + Text = "This will overwrite your project files with the current Studio state. This cannot be undone.", + TextWrapped = true, + FontFace = theme.Font.Main, + TextSize = theme.TextSize.Body, + TextColor3 = theme.TextColor, + Size = UDim2.new(1, 0, 0, 0), + AutomaticSize = Enum.AutomaticSize.Y, + BackgroundTransparency = 1, + LayoutOrder = 1, + }), + Buttons = e("Frame", { + Size = UDim2.new(1, 0, 0, 34), + BackgroundTransparency = 1, + LayoutOrder = 2, + }, { + Layout = e("UIListLayout", { + HorizontalAlignment = Enum.HorizontalAlignment.Right, + FillDirection = Enum.FillDirection.Horizontal, + SortOrder = Enum.SortOrder.LayoutOrder, + Padding = UDim.new(0, 10), + }), + Cancel = e(TextButton, { + text = "Cancel", + style = "Bordered", + layoutOrder = 1, + onClick = function() + self:setState({ showingSyncbackConfirm = false }) + end, + }), + Confirm = e(TextButton, { + text = "Syncback", + style = "Danger", + layoutOrder = 2, + onClick = function() + self:performSyncback() + end, + }), + }), + }) + end), + }), + + toggleAction = e(StudioPluginAction, { name = "AtlasConnection", title = "Atlas: Connect/Disconnect", description = "Toggles the server for an Atlas sync session", diff --git a/rojo-test/serve-tests/live_syncback/default.project.json5 b/rojo-test/serve-tests/live_syncback/default.project.json5 new file mode 100644 index 00000000..b96dcdeb --- /dev/null +++ b/rojo-test/serve-tests/live_syncback/default.project.json5 @@ -0,0 +1,18 @@ +{ + "name": "live_syncback", + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "src/shared" + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "$path": "src/server" + }, + "Workspace": { + "$className": "Workspace", + "$path": "src/workspace" + } + } +} diff --git a/rojo-test/serve-tests/live_syncback/src/server/OldScript.server.luau b/rojo-test/serve-tests/live_syncback/src/server/OldScript.server.luau new file mode 100644 index 00000000..bbefe7fa --- /dev/null +++ b/rojo-test/serve-tests/live_syncback/src/server/OldScript.server.luau @@ -0,0 +1 @@ +print("old server script") diff --git a/rojo-test/serve-tests/live_syncback/src/shared/OldModule.luau b/rojo-test/serve-tests/live_syncback/src/shared/OldModule.luau new file mode 100644 index 00000000..bf545338 --- /dev/null +++ b/rojo-test/serve-tests/live_syncback/src/shared/OldModule.luau @@ -0,0 +1 @@ +return "old" diff --git a/rojo-test/serve-tests/live_syncback/src/workspace/OldPart.model.json5 b/rojo-test/serve-tests/live_syncback/src/workspace/OldPart.model.json5 new file mode 100644 index 00000000..063296c9 --- /dev/null +++ b/rojo-test/serve-tests/live_syncback/src/workspace/OldPart.model.json5 @@ -0,0 +1,6 @@ +{ + "className": "Part", + "properties": { + "Anchored": true + } +} diff --git a/rojo-test/serve-tests/live_syncback_place_ids/default.project.json5 b/rojo-test/serve-tests/live_syncback_place_ids/default.project.json5 new file mode 100644 index 00000000..73d8de9a --- /dev/null +++ b/rojo-test/serve-tests/live_syncback_place_ids/default.project.json5 @@ -0,0 +1,16 @@ +{ + "name": "live_syncback_place_ids", + "servePlaceIds": [123], + "blockedPlaceIds": [999], + "tree": { + "$className": "DataModel", + "ReplicatedStorage": { + "$className": "ReplicatedStorage", + "$path": "src/shared" + }, + "ServerScriptService": { + "$className": "ServerScriptService", + "$path": "src/server" + } + } +} diff --git a/rojo-test/serve-tests/live_syncback_place_ids/src/server/Placeholder.server.luau b/rojo-test/serve-tests/live_syncback_place_ids/src/server/Placeholder.server.luau new file mode 100644 index 00000000..09a7b059 --- /dev/null +++ b/rojo-test/serve-tests/live_syncback_place_ids/src/server/Placeholder.server.luau @@ -0,0 +1 @@ +-- placeholder diff --git a/rojo-test/serve-tests/live_syncback_place_ids/src/shared/Placeholder.luau b/rojo-test/serve-tests/live_syncback_place_ids/src/shared/Placeholder.luau new file mode 100644 index 00000000..15e53c4a --- /dev/null +++ b/rojo-test/serve-tests/live_syncback_place_ids/src/shared/Placeholder.luau @@ -0,0 +1 @@ +return nil diff --git a/src/cli/serve.rs b/src/cli/serve.rs index df91220a..831a4542 100644 --- a/src/cli/serve.rs +++ b/src/cli/serve.rs @@ -1,13 +1,26 @@ use std::{ - net::{IpAddr, Ipv4Addr}, - path::PathBuf, + collections::HashMap, + io::Cursor, + mem::forget, + net::{IpAddr, Ipv4Addr, SocketAddr}, + path::{Path, PathBuf}, + process::{Command, Stdio}, sync::Arc, }; +use anyhow::Context; use clap::Parser; use memofs::Vfs; +use rbx_dom_weak::{types::Ref, types::Variant, InstanceBuilder, WeakDom}; -use crate::{serve_session::ServeSession, web::LiveServer}; +use crate::{ + serve_session::ServeSession, + syncback::syncback_loop, + web::{ + interface::{ServerExitReason, SyncbackPayload}, + LiveServer, + }, +}; use super::resolve_path; @@ -35,30 +48,190 @@ impl ServeCommand { pub fn run(self) -> anyhow::Result<()> { let project_path = resolve_path(&self.project); - let (vfs, critical_errors) = Vfs::new_default_with_errors(); - - let session = Arc::new(ServeSession::new(vfs, project_path, Some(critical_errors))?); - - let ip = self - .address - .or_else(|| session.serve_address()) - .unwrap_or(DEFAULT_BIND_ADDRESS.into()); - - let port = self - .port - .or_else(|| session.project_port()) - .unwrap_or(DEFAULT_PORT); - - let server = LiveServer::new(session); + let (ip, port) = { + let vfs = Vfs::new_oneshot(); + let session = ServeSession::new_oneshot(vfs, project_path.clone())?; + let ip = self + .address + .or_else(|| session.serve_address()) + .unwrap_or(DEFAULT_BIND_ADDRESS.into()); + let port = self + .port + .or_else(|| session.project_port()) + .unwrap_or(DEFAULT_PORT); + forget(session); + (ip, port) + }; + let addr: SocketAddr = (ip, port).into(); let host = if ip.is_loopback() { "localhost".to_owned() } else { ip.to_string() }; - log::info!("Listening: http://{}:{}", host, port); - server.start((ip, port).into()); - Ok(()) + loop { + let (vfs, critical_errors) = Vfs::new_default_with_errors(); + let session = + Arc::new(ServeSession::new(vfs, project_path.clone(), Some(critical_errors))?); + let server = LiveServer::new(session); + + log::info!("Listening: http://{}:{}", host, port); + + match server.start(addr) { + ServerExitReason::SyncbackRequested(payload) => { + log::info!("Live syncback requested, running..."); + run_live_syncback(&project_path, payload)?; + log::info!("Syncback complete, restarting serve..."); + continue; + } + } + } + } +} + +fn run_live_syncback(project_path: &Path, payload: SyncbackPayload) -> anyhow::Result<()> { + let new_dom = build_dom_from_chunks(payload)?; + + let vfs = Vfs::new_oneshot(); + let session_old = ServeSession::new_oneshot(vfs, project_path.to_path_buf())?; + + let mut dom_old = session_old.tree(); + + let syncback_timer = std::time::Instant::now(); + log::info!("Beginning live syncback (clean mode)..."); + + let result = syncback_loop( + session_old.vfs(), + &mut dom_old, + new_dom, + session_old.root_project(), + false, // incremental = false → clean mode + )?; + + log::debug!( + "Syncback finished in {:.02}s", + syncback_timer.elapsed().as_secs_f32() + ); + + let base_path = session_old.root_project().folder_location(); + drop(dom_old); + + log::info!("Writing to the file system..."); + result + .fs_snapshot + .write_to_vfs_parallel(base_path, session_old.vfs())?; + + log::info!( + "Finished live syncback: wrote {} files/folders, removed {}.", + result.fs_snapshot.added_paths().len(), + result.fs_snapshot.removed_paths().len() + ); + + refresh_git_index(base_path); + + forget(session_old); + + Ok(()) +} + +fn build_dom_from_chunks(payload: SyncbackPayload) -> anyhow::Result { + let mut dom = WeakDom::new(InstanceBuilder::new("DataModel")); + let root_ref = dom.root_ref(); + + let mut global_ref_map: HashMap = HashMap::new(); + + for chunk in &payload.services { + let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data)) + .with_context(|| format!("Failed to parse rbxm for service {}", chunk.class_name))?; + + let service_ref = dom.insert(root_ref, InstanceBuilder::new(&chunk.class_name)); + + for &child_ref in chunk_dom.root().children() { + deep_clone_into(&chunk_dom, &mut dom, child_ref, service_ref, &mut global_ref_map); + } + } + + fixup_ref_properties(&mut dom, &global_ref_map); + + Ok(dom) +} + +fn deep_clone_into( + source: &WeakDom, + target: &mut WeakDom, + source_ref: Ref, + target_parent: Ref, + ref_map: &mut HashMap, +) { + let inst = source.get_by_ref(source_ref).unwrap(); + let mut builder = InstanceBuilder::new(inst.class.as_str()).with_name(inst.name.as_str()); + + for (key, value) in &inst.properties { + builder = builder.with_property(key.as_str(), value.clone()); + } + + let new_ref = target.insert(target_parent, builder); + ref_map.insert(source_ref, new_ref); + + for &child_ref in inst.children() { + deep_clone_into(source, target, child_ref, new_ref, ref_map); + } +} + +fn fixup_ref_properties(dom: &mut WeakDom, ref_map: &HashMap) { + let all_refs: Vec = ref_map.values().copied().collect(); + for inst_ref in all_refs { + let props_to_fix: Vec<(String, Ref)> = { + let inst = dom.get_by_ref(inst_ref).unwrap(); + inst.properties + .iter() + .filter_map(|(key, value)| { + if let Variant::Ref(r) = value { + if let Some(&mapped) = ref_map.get(r) { + Some((key.to_string(), mapped)) + } else { + None + } + } else { + None + } + }) + .collect() + }; + + if !props_to_fix.is_empty() { + let inst = dom.get_by_ref_mut(inst_ref).unwrap(); + for (key, new_ref) in props_to_fix { + inst.properties.insert(key.into(), Variant::Ref(new_ref)); + } + } + } +} + +fn refresh_git_index(project_dir: &Path) { + let mut check_dir = Some(project_dir); + let mut is_git_repo = false; + while let Some(dir) = check_dir { + if dir.join(".git").exists() { + is_git_repo = true; + break; + } + check_dir = dir.parent(); + } + + if is_git_repo { + log::info!("Refreshing git index..."); + match Command::new("git") + .args(["update-index", "--refresh", "-q"]) + .current_dir(project_dir) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(_) => log::info!("Git index refreshed."), + Err(e) => log::warn!("Failed to run git update-index --refresh: {}", e), + } } } diff --git a/src/web/api.rs b/src/web/api.rs index bef4d967..c40f16cc 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -29,8 +29,9 @@ use crate::{ web::{ interface::{ ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse, - ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage, - WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION, + ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, + SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse, + PROTOCOL_VERSION, SERVER_VERSION, }, util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack}, }, @@ -122,6 +123,7 @@ fn variant_to_json(variant: &Variant) -> Option { pub async fn call( serve_session: Arc, mut request: Request, + syncback_signal: Arc, ) -> Response> { let service = ApiService::new(serve_session); @@ -153,6 +155,9 @@ pub async fn call( service.handle_api_open(request).await } (&Method::POST, "/api/write") => service.handle_api_write(request).await, + (&Method::POST, "/api/syncback") => { + handle_api_syncback(request, &service, syncback_signal).await + } (&Method::GET, "/api/validate-tree") => service.handle_api_validate_tree().await, (_method, path) => msgpack( @@ -162,6 +167,96 @@ pub async fn call( } } +async fn handle_api_syncback( + request: Request, + service: &ApiService, + syncback_signal: Arc, +) -> Response> { + let body = match request.into_body().collect().await { + Ok(collected) => collected.to_bytes(), + Err(err) => { + return msgpack( + ErrorResponse::bad_request(format!("Failed to read request body: {err}")), + StatusCode::BAD_REQUEST, + ); + } + }; + + let syncback_request: SyncbackRequest = match deserialize_msgpack(&body) { + Ok(req) => req, + Err(err) => { + return msgpack( + ErrorResponse::bad_request(format!( + "Failed to deserialize syncback request: {err}" + )), + StatusCode::BAD_REQUEST, + ); + } + }; + + if syncback_request.protocol_version != PROTOCOL_VERSION { + return msgpack( + ErrorResponse::bad_request(format!( + "Protocol version mismatch: expected {}, got {}", + PROTOCOL_VERSION, syncback_request.protocol_version + )), + StatusCode::BAD_REQUEST, + ); + } + + let server_semver = SERVER_VERSION; + let server_parts: Vec<&str> = server_semver.split('.').collect(); + let client_parts: Vec<&str> = syncback_request.server_version.split('.').collect(); + let server_major_minor = server_parts.get(..2); + let client_major_minor = client_parts.get(..2); + if server_major_minor != client_major_minor { + return msgpack( + ErrorResponse::bad_request(format!( + "Server version mismatch: server is {}, plugin expects {}", + SERVER_VERSION, syncback_request.server_version + )), + StatusCode::BAD_REQUEST, + ); + } + + if let Some(place_id) = syncback_request.place_id { + if let Some(expected) = service.serve_session.serve_place_ids() { + if !expected.contains(&place_id) { + return msgpack( + ErrorResponse::bad_request(format!( + "Place ID {} is not in the servePlaceIds whitelist", + place_id + )), + StatusCode::FORBIDDEN, + ); + } + } + if let Some(blocked) = service.serve_session.blocked_place_ids() { + if blocked.contains(&place_id) { + return msgpack( + ErrorResponse::bad_request(format!( + "Place ID {} is in the blockedPlaceIds list", + place_id + )), + StatusCode::FORBIDDEN, + ); + } + } + } + + log::info!( + "Live syncback requested with {} service chunks", + syncback_request.services.len() + ); + + let payload = SyncbackPayload { + services: syncback_request.services, + }; + syncback_signal.fire(payload); + + msgpack_ok(&serde_json::json!({"status": "syncback_initiated"})) +} + pub struct ApiService { serve_session: Arc, suppressed_paths: Arc>>, diff --git a/src/web/interface.rs b/src/web/interface.rs index dd6e4281..4fa7b796 100644 --- a/src/web/interface.rs +++ b/src/web/interface.rs @@ -27,6 +27,36 @@ pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION"); /// Current protocol version, which is required to match. pub const PROTOCOL_VERSION: u64 = 6; +/// A single service's children serialized as rbxm binary data. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServiceChunk { + pub class_name: String, + #[serde(with = "serde_bytes")] + pub data: Vec, +} + +/// Incoming request from the plugin for live syncback. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncbackRequest { + pub protocol_version: u64, + pub server_version: String, + pub place_id: Option, + pub services: Vec, +} + +/// Payload passed from the API handler to the serve loop after validation. +#[derive(Debug)] +pub struct SyncbackPayload { + pub services: Vec, +} + +/// Why the live server exited its accept loop. +pub enum ServerExitReason { + SyncbackRequested(SyncbackPayload), +} + /// Message returned by Rojo API when a change has occurred. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/src/web/mod.rs b/src/web/mod.rs index 22121be2..3813562a 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -10,7 +10,7 @@ mod util; use std::convert::Infallible; use std::net::SocketAddr; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use hyper::body::Incoming; use hyper::server::conn::http1; @@ -19,52 +19,103 @@ use hyper::Request; use hyper_util::rt::TokioIo; use tokio::net::TcpListener; use tokio::runtime::Runtime; +use tokio::sync::Notify; use crate::serve_session::ServeSession; +use self::interface::{ServerExitReason, SyncbackPayload}; + +/// Shared signal for the syncback endpoint to deposit its payload and notify +/// the accept loop to shut down. +pub struct SyncbackSignal { + payload: Mutex>, + notify: Notify, +} + +impl SyncbackSignal { + pub fn new() -> Self { + Self { + payload: Mutex::new(None), + notify: Notify::new(), + } + } + + pub fn fire(&self, payload: SyncbackPayload) { + *self.payload.lock().unwrap() = Some(payload); + self.notify.notify_one(); + } + + pub fn take_payload(&self) -> Option { + self.payload.lock().unwrap().take() + } +} + pub struct LiveServer { serve_session: Arc, + syncback_signal: Arc, } impl LiveServer { pub fn new(serve_session: Arc) -> Self { - LiveServer { serve_session } + LiveServer { + serve_session, + syncback_signal: Arc::new(SyncbackSignal::new()), + } } - pub fn start(self, address: SocketAddr) { + pub fn start(self, address: SocketAddr) -> ServerExitReason { let serve_session = Arc::clone(&self.serve_session); + let syncback_signal = Arc::clone(&self.syncback_signal); let rt = Runtime::new().unwrap(); - rt.block_on(async move { + let exit_reason = rt.block_on(async move { let listener = TcpListener::bind(address).await.unwrap(); loop { - let (stream, _) = listener.accept().await.unwrap(); - let io = TokioIo::new(stream); - let serve_session = Arc::clone(&serve_session); - - tokio::spawn(async move { - let service = service_fn(move |req: Request| { + tokio::select! { + result = listener.accept() => { + let (stream, _) = result.unwrap(); + let io = TokioIo::new(stream); let serve_session = Arc::clone(&serve_session); + let syncback_signal = Arc::clone(&syncback_signal); - async move { - if req.uri().path().starts_with("/api") { - Ok::<_, Infallible>(api::call(serve_session, req).await) - } else { - Ok::<_, Infallible>(ui::call(serve_session, req).await) + tokio::spawn(async move { + let service = service_fn(move |req: Request| { + let serve_session = Arc::clone(&serve_session); + let syncback_signal = Arc::clone(&syncback_signal); + + async move { + if req.uri().path().starts_with("/api") { + Ok::<_, Infallible>( + api::call(serve_session, req, syncback_signal).await, + ) + } else { + Ok::<_, Infallible>(ui::call(serve_session, req).await) + } + } + }); + + if let Err(err) = http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await + { + log::error!("Error serving connection: {err}"); } - } - }); - - if let Err(err) = http1::Builder::new() - .serve_connection(io, service) - .with_upgrades() - .await - { - log::error!("Error serving connection: {err}"); + }); } - }); + _ = syncback_signal.notify.notified() => { + break; + } + } } + + let payload = syncback_signal + .take_payload() + .expect("Syncback signal fired but no payload was deposited"); + ServerExitReason::SyncbackRequested(payload) }); + + exit_reason } } diff --git a/tests/rojo_test/serve_util.rs b/tests/rojo_test/serve_util.rs index ce56845b..a29d33c0 100644 --- a/tests/rojo_test/serve_util.rs +++ b/tests/rojo_test/serve_util.rs @@ -470,6 +470,221 @@ fn get_port_number() -> usize { port } +/// Build a ServiceChunk by serializing child instances into rbxm format. +pub fn make_service_chunk( + class_name: &str, + children: Vec, +) -> librojo::web_api::ServiceChunk { + use rbx_dom_weak::WeakDom; + + let mut dom = WeakDom::new(rbx_dom_weak::InstanceBuilder::new("DataModel")); + let root = dom.root_ref(); + for child in children { + dom.insert(root, child); + } + let refs: Vec = dom.root().children().to_vec(); + let mut buf = Vec::new(); + rbx_binary::to_writer(&mut buf, &dom, &refs).unwrap(); + librojo::web_api::ServiceChunk { + class_name: class_name.to_string(), + data: buf, + } +} + +/// Build a complete rbxl file from service chunks, mirroring the same data +/// that live syncback receives. Used for CLI parity testing. +pub fn make_rbxl_from_chunks(chunks: &[librojo::web_api::ServiceChunk]) -> Vec { + use rbx_dom_weak::WeakDom; + use std::collections::HashMap; + use std::io::Cursor; + + let mut dom = WeakDom::new(rbx_dom_weak::InstanceBuilder::new("DataModel")); + let root_ref = dom.root_ref(); + + for chunk in chunks { + let chunk_dom = rbx_binary::from_reader(Cursor::new(&chunk.data)) + .unwrap_or_else(|e| panic!("Failed to parse rbxm for {}: {e}", chunk.class_name)); + + let service_ref = + dom.insert(root_ref, rbx_dom_weak::InstanceBuilder::new(&chunk.class_name)); + + let mut ref_map: HashMap = HashMap::new(); + for &child_ref in chunk_dom.root().children() { + deep_clone_into_test(&chunk_dom, &mut dom, child_ref, service_ref, &mut ref_map); + } + + let all_refs: Vec = ref_map.values().copied().collect(); + for inst_ref in all_refs { + let props_to_fix: Vec<(String, Ref)> = { + let inst = dom.get_by_ref(inst_ref).unwrap(); + inst.properties + .iter() + .filter_map(|(key, value)| { + if let rbx_dom_weak::types::Variant::Ref(r) = value { + ref_map.get(r).map(|&mapped| (key.to_string(), mapped)) + } else { + None + } + }) + .collect() + }; + if !props_to_fix.is_empty() { + let inst = dom.get_by_ref_mut(inst_ref).unwrap(); + for (key, new_ref) in props_to_fix { + inst.properties + .insert(key.into(), rbx_dom_weak::types::Variant::Ref(new_ref)); + } + } + } + } + + let service_refs: Vec = dom.root().children().to_vec(); + let mut buf = Vec::new(); + rbx_binary::to_writer(&mut buf, &dom, &service_refs).unwrap(); + buf +} + +fn deep_clone_into_test( + source: &rbx_dom_weak::WeakDom, + target: &mut rbx_dom_weak::WeakDom, + source_ref: Ref, + target_parent: Ref, + ref_map: &mut std::collections::HashMap, +) { + let inst = source.get_by_ref(source_ref).unwrap(); + let mut builder = + rbx_dom_weak::InstanceBuilder::new(inst.class.as_str()).with_name(inst.name.as_str()); + + for (key, value) in &inst.properties { + builder = builder.with_property(key.as_str(), value.clone()); + } + + let new_ref = target.insert(target_parent, builder); + ref_map.insert(source_ref, new_ref); + + for &child_ref in inst.children() { + deep_clone_into_test(source, target, child_ref, new_ref, ref_map); + } +} + +/// Run CLI syncback on a fresh copy of a fixture using the same data +/// that was sent to live syncback. Returns the temp dir (keep alive) and +/// project path. +pub fn run_cli_syncback_on_chunks( + fixture_name: &str, + chunks: &[librojo::web_api::ServiceChunk], +) -> (TempDir, PathBuf) { + use crate::rojo_test::io_util::{copy_recursive, ROJO_PATH, SERVE_TESTS_PATH}; + + let source_path = Path::new(SERVE_TESTS_PATH).join(fixture_name); + let dir = tempdir().expect("Couldn't create temp dir for CLI syncback"); + let project_path = dir.path().join(fixture_name); + fs::create_dir(&project_path).expect("Couldn't create project subdirectory"); + copy_recursive(&source_path, &project_path).expect("Couldn't copy fixture"); + + let rbxl_data = make_rbxl_from_chunks(chunks); + let rbxl_path = dir.path().join("input.rbxl"); + fs::write(&rbxl_path, rbxl_data).expect("Failed to write rbxl"); + + let output = std::process::Command::new(ROJO_PATH) + .args([ + "syncback", + project_path.to_str().unwrap(), + "--input", + rbxl_path.to_str().unwrap(), + ]) + .output() + .expect("Failed to run atlas syncback"); + + if !output.status.success() { + panic!( + "atlas syncback failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + (dir, project_path) +} + +impl TestServeSession { + /// Post to /api/syncback with raw bytes. Returns the full response + /// for status code inspection. + pub fn post_api_syncback_raw(&self, body: Vec) -> reqwest::blocking::Response { + let url = format!("http://localhost:{}/api/syncback", self.port); + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(120)) + .build() + .expect("Failed to build reqwest client"); + client + .post(url) + .body(body) + .send() + .expect("Failed to send syncback request") + } + + /// Post to /api/syncback with correct version/protocol info. + /// Panics if the response is not 200. + pub fn post_api_syncback( + &self, + place_id: Option, + services: Vec, + ) { + use serde::Serialize; + + let request = serde_json::json!({ + "protocolVersion": librojo::web_api::PROTOCOL_VERSION, + "serverVersion": env!("CARGO_PKG_VERSION"), + "placeId": place_id, + "services": services, + }); + + 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 syncback request"); + + let response = self.post_api_syncback_raw(body); + assert!( + response.status().is_success(), + "Syncback request failed with status {}: {}", + response.status(), + response.text().unwrap_or_default() + ); + } + + /// Wait for the server to come back online after a syncback-triggered + /// restart. Uses longer timeouts than initial startup since syncback + /// runs between teardown and restart. + pub fn wait_to_come_back_online(&self) -> ServerInfoResponse { + const BASE_DURATION_MS: f32 = 200.0; + const EXP_BACKOFF_FACTOR: f32 = 1.3; + const MAX_TRIES: u32 = 20; + + for i in 1..=MAX_TRIES { + let info = match self.get_api_rojo() { + Ok(info) => info, + 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); + thread::sleep(retry_time); + continue; + } + }; + + return info; + } + + panic!( + "Server did not come back online after syncback ({} tries)", + MAX_TRIES + ); + } +} + /// Extract the message cursor from a SocketPacket for use in subsequent /// WebSocket subscriptions when chaining multi-step tests. pub fn get_message_cursor(packet: &SocketPacket) -> u32 { diff --git a/tests/tests/live_syncback.rs b/tests/tests/live_syncback.rs new file mode 100644 index 00000000..16ac702a --- /dev/null +++ b/tests/tests/live_syncback.rs @@ -0,0 +1,536 @@ +use std::{fs, thread, time::Duration}; + +use rbx_dom_weak::InstanceBuilder; + +use crate::rojo_test::{ + roundtrip_util::assert_dirs_equal, + serve_util::{make_service_chunk, run_cli_syncback_on_chunks, run_serve_test}, +}; + +use librojo::web_api::{ServiceChunk, SocketPacketType, PROTOCOL_VERSION}; + +fn assert_live_matches_cli( + fixture_name: &str, + chunks: &[ServiceChunk], + place_id: Option, +) { + run_serve_test(fixture_name, |session, _| { + session.post_api_syncback(place_id, chunks.to_vec()); + session.wait_to_come_back_online(); + + let (_cli_dir, cli_path) = run_cli_syncback_on_chunks(fixture_name, chunks); + assert_dirs_equal(session.path(), &cli_path); + }); +} + +// ── CLI parity tests ──────────────────────────────────────────── + +#[test] +fn parity_scripts() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![ + InstanceBuilder::new("ModuleScript") + .with_name("Utils") + .with_property("Source", rbx_dom_weak::types::Variant::String("return {}".into())), + InstanceBuilder::new("Script") + .with_name("Main") + .with_property("Source", rbx_dom_weak::types::Variant::String("print('hi')".into())) + .with_property( + "RunContext", + rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)), + ), + ], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_models_with_properties() { + let chunks = vec![make_service_chunk( + "Workspace", + vec![InstanceBuilder::new("Part") + .with_name("TestPart") + .with_property("Anchored", rbx_dom_weak::types::Variant::Bool(true)) + .with_property( + "Size", + rbx_dom_weak::types::Variant::Vector3(rbx_dom_weak::types::Vector3::new( + 4.0, 2.0, 6.0, + )), + )], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_special_names() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![ + InstanceBuilder::new("Folder").with_name("What?"), + InstanceBuilder::new("Folder").with_name("Key:Value"), + InstanceBuilder::new("Folder").with_name("A/B"), + ], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_duplicate_names() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![ + InstanceBuilder::new("Folder").with_name("Data"), + InstanceBuilder::new("Folder").with_name("Data"), + InstanceBuilder::new("Folder").with_name("Data"), + ], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_deep_hierarchy() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder") + .with_name("A") + .with_children(vec![InstanceBuilder::new("Folder") + .with_name("B") + .with_children(vec![InstanceBuilder::new("Folder") + .with_name("C") + .with_children(vec![InstanceBuilder::new("Folder") + .with_name("D") + .with_children(vec![InstanceBuilder::new("ModuleScript") + .with_name("Leaf") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return true".into()), + )])])])])], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_mixed_file_types() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![ + InstanceBuilder::new("StringValue") + .with_name("MyString") + .with_property("Value", rbx_dom_weak::types::Variant::String("hello world".into())), + InstanceBuilder::new("Folder").with_name("Container"), + InstanceBuilder::new("ModuleScript") + .with_name("DataModule") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return {a=1}".into()), + ), + ], + )]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_multi_service() { + let chunks = vec![ + make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("ModuleScript") + .with_name("SharedUtil") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return {}".into()), + )], + ), + make_service_chunk( + "ServerScriptService", + vec![InstanceBuilder::new("Script") + .with_name("ServerMain") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("print('server')".into()), + ) + .with_property( + "RunContext", + rbx_dom_weak::types::Variant::Enum(rbx_dom_weak::types::Enum::from_u32(1)), + )], + ), + make_service_chunk( + "Workspace", + vec![InstanceBuilder::new("Part") + .with_name("Floor") + .with_property("Anchored", rbx_dom_weak::types::Variant::Bool(true))], + ), + ]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +#[test] +fn parity_empty_services_mixed() { + let chunks = vec![ + make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("Stuff")], + ), + make_service_chunk("ServerScriptService", vec![]), + ]; + assert_live_matches_cli("live_syncback", &chunks, None); +} + +// ── Validation / rejection tests ───────────────────────────────── + +#[test] +fn rejects_bad_protocol() { + run_serve_test("live_syncback", |session, _| { + let initial_info = session.get_api_rojo().unwrap(); + + let request = serde_json::json!({ + "protocolVersion": 9999, + "serverVersion": env!("CARGO_PKG_VERSION"), + "placeId": null, + "services": [], + }); + + let mut body = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut body) + .with_human_readable() + .with_struct_map(); + serde::Serialize::serialize(&request, &mut serializer).unwrap(); + + let response = session.post_api_syncback_raw(body); + assert!( + !response.status().is_success(), + "Expected rejection for bad protocol, got {}", + response.status() + ); + + let after_info = session.get_api_rojo().unwrap(); + assert_eq!( + initial_info.session_id, after_info.session_id, + "Server should not have restarted" + ); + }); +} + +#[test] +fn rejects_bad_version() { + run_serve_test("live_syncback", |session, _| { + let initial_info = session.get_api_rojo().unwrap(); + + let request = serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "serverVersion": "0.0.0", + "placeId": null, + "services": [], + }); + + let mut body = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut body) + .with_human_readable() + .with_struct_map(); + serde::Serialize::serialize(&request, &mut serializer).unwrap(); + + let response = session.post_api_syncback_raw(body); + assert!( + !response.status().is_success(), + "Expected rejection for bad version, got {}", + response.status() + ); + + let after_info = session.get_api_rojo().unwrap(); + assert_eq!( + initial_info.session_id, after_info.session_id, + "Server should not have restarted" + ); + }); +} + +#[test] +fn rejects_blocked_place() { + run_serve_test("live_syncback_place_ids", |session, _| { + let initial_info = session.get_api_rojo().unwrap(); + + let request = serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "serverVersion": env!("CARGO_PKG_VERSION"), + "placeId": 999, + "services": [], + }); + + let mut body = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut body) + .with_human_readable() + .with_struct_map(); + serde::Serialize::serialize(&request, &mut serializer).unwrap(); + + let response = session.post_api_syncback_raw(body); + assert_eq!( + response.status().as_u16(), + 403, + "Expected 403 for blocked place" + ); + + let after_info = session.get_api_rojo().unwrap(); + assert_eq!( + initial_info.session_id, after_info.session_id, + "Server should not have restarted" + ); + }); +} + +#[test] +fn rejects_unlisted_place() { + run_serve_test("live_syncback_place_ids", |session, _| { + let initial_info = session.get_api_rojo().unwrap(); + + let request = serde_json::json!({ + "protocolVersion": PROTOCOL_VERSION, + "serverVersion": env!("CARGO_PKG_VERSION"), + "placeId": 456, + "services": [], + }); + + let mut body = Vec::new(); + let mut serializer = rmp_serde::Serializer::new(&mut body) + .with_human_readable() + .with_struct_map(); + serde::Serialize::serialize(&request, &mut serializer).unwrap(); + + let response = session.post_api_syncback_raw(body); + assert_eq!( + response.status().as_u16(), + 403, + "Expected 403 for unlisted place" + ); + + let after_info = session.get_api_rojo().unwrap(); + assert_eq!( + initial_info.session_id, after_info.session_id, + "Server should not have restarted" + ); + }); +} + +#[test] +fn allows_whitelisted_place() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("Allowed")], + )]; + run_serve_test("live_syncback_place_ids", |session, _| { + session.post_api_syncback(Some(123), chunks); + let new_info = session.wait_to_come_back_online(); + assert!( + !new_info.project_name.is_empty(), + "Server should be back online with a project" + ); + }); +} + +#[test] +fn allows_any_place_when_unrestricted() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("Any")], + )]; + run_serve_test("live_syncback", |session, _| { + session.post_api_syncback(Some(99999), chunks); + session.wait_to_come_back_online(); + }); +} + +// ── Lifecycle / edge case tests ────────────────────────────────── + +#[test] +fn server_comes_back_functional() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("ModuleScript") + .with_name("TestModule") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return 42".into()), + )], + )]; + + run_serve_test("live_syncback", |session, _| { + session.post_api_syncback(None, chunks); + let new_info = session.wait_to_come_back_online(); + + let read = session.get_api_read(new_info.root_instance_id).unwrap(); + assert!( + !read.instances.is_empty(), + "Should be able to read instances from restarted server" + ); + + let module_path = session.path().join("src/shared/TestModule.luau"); + let socket_packet = session.recv_socket_packet(SocketPacketType::Messages, 0, || { + fs::write(&module_path, "return 99").unwrap(); + }); + assert!( + socket_packet.is_ok(), + "WebSocket should receive patches after file modification" + ); + }); +} + +#[test] +fn syncback_twice_different_data() { + let chunks_a = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("DataA")], + )]; + let chunks_b = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("Folder").with_name("DataB")], + )]; + + run_serve_test("live_syncback", |session, _| { + session.post_api_syncback(None, chunks_a); + session.wait_to_come_back_online(); + + assert!( + session.path().join("src/shared/DataA").exists(), + "First syncback should create DataA" + ); + + session.post_api_syncback(None, chunks_b); + session.wait_to_come_back_online(); + + assert!( + !session.path().join("src/shared/DataA").exists(), + "DataA should be gone after second syncback (clean mode)" + ); + assert!( + session.path().join("src/shared/DataB").exists(), + "Second syncback should create DataB" + ); + }); +} + +#[test] +fn syncback_replaces_all_old_files() { + let chunks = vec![make_service_chunk( + "ReplicatedStorage", + vec![InstanceBuilder::new("ModuleScript") + .with_name("NewFile") + .with_property( + "Source", + rbx_dom_weak::types::Variant::String("return 'new'".into()), + )], + )]; + + run_serve_test("live_syncback", |session, _| { + assert!( + session.path().join("src/shared/OldModule.luau").exists(), + "OldModule should exist before syncback" + ); + + session.post_api_syncback(None, chunks); + session.wait_to_come_back_online(); + + assert!( + !session.path().join("src/shared/OldModule.luau").exists(), + "OldModule should be gone after clean syncback" + ); + assert!( + session.path().join("src/shared/NewFile.luau").exists(), + "NewFile should exist after syncback" + ); + }); +} + +// ── Round-trip identity test ───────────────────────────────────── + +#[test] +fn roundtrip_build_syncback_rebuild() { + use crate::rojo_test::roundtrip_util::run_rojo_build; + use std::io::Cursor; + + run_serve_test("live_syncback", |session, _| { + let (_build_dir, rbxl_path_a) = run_rojo_build(session.path(), "build_a.rbxl"); + let rbxl_data_a = fs::read(&rbxl_path_a).unwrap(); + + let dom_a = rbx_binary::from_reader(Cursor::new(&rbxl_data_a)).unwrap(); + let mut chunks = Vec::new(); + for &service_ref in dom_a.root().children() { + let service = dom_a.get_by_ref(service_ref).unwrap(); + let child_refs: Vec = service.children().to_vec(); + if child_refs.is_empty() { + continue; + } + let mut buf = Vec::new(); + rbx_binary::to_writer(&mut buf, &dom_a, &child_refs).unwrap(); + chunks.push(ServiceChunk { + class_name: service.class.to_string(), + data: buf, + }); + } + + session.post_api_syncback(None, chunks); + session.wait_to_come_back_online(); + + thread::sleep(Duration::from_millis(500)); + + let (_build_dir_b, rbxl_path_b) = run_rojo_build(session.path(), "build_b.rbxl"); + let rbxl_data_b = fs::read(&rbxl_path_b).unwrap(); + let dom_b = rbx_binary::from_reader(Cursor::new(&rbxl_data_b)).unwrap(); + + fn compare_trees( + dom_a: &rbx_dom_weak::WeakDom, + ref_a: rbx_dom_weak::types::Ref, + dom_b: &rbx_dom_weak::WeakDom, + ref_b: rbx_dom_weak::types::Ref, + path: &str, + ) { + let inst_a = dom_a.get_by_ref(ref_a).unwrap(); + let inst_b = dom_b.get_by_ref(ref_b).unwrap(); + + assert_eq!( + inst_a.name, inst_b.name, + "Name mismatch at {path}: {:?} vs {:?}", + inst_a.name, inst_b.name + ); + assert_eq!( + inst_a.class, inst_b.class, + "Class mismatch at {path}: {:?} vs {:?}", + inst_a.class, inst_b.class + ); + + let mut children_a: Vec<_> = inst_a + .children() + .iter() + .map(|&r| { + let c = dom_a.get_by_ref(r).unwrap(); + (c.name.clone(), c.class.clone(), r) + }) + .collect(); + children_a.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1))); + + let mut children_b: Vec<_> = inst_b + .children() + .iter() + .map(|&r| { + let c = dom_b.get_by_ref(r).unwrap(); + (c.name.clone(), c.class.clone(), r) + }) + .collect(); + children_b.sort_by(|a, b| (&a.0, &a.1).cmp(&(&b.0, &b.1))); + + assert_eq!( + children_a.len(), + children_b.len(), + "Child count mismatch at {path}: {} vs {}", + children_a.len(), + children_b.len() + ); + + for (ca, cb) in children_a.iter().zip(children_b.iter()) { + let child_path = format!("{path}/{}", ca.0); + compare_trees(dom_a, ca.2, dom_b, cb.2, &child_path); + } + } + + compare_trees(&dom_a, dom_a.root_ref(), &dom_b, dom_b.root_ref(), "root"); + }); +} diff --git a/tests/tests/mod.rs b/tests/tests/mod.rs index e78b98f4..efcd3121 100644 --- a/tests/tests/mod.rs +++ b/tests/tests/mod.rs @@ -3,6 +3,7 @@ mod clean_mode; mod clean_mode_stress; mod connected_mode; mod git_sync_defaults; +mod live_syncback; mod matching_fixtures; mod serve; mod syncback;