mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
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.
This commit is contained in:
parent
6f8e5a5cf2
commit
307ac81aec
17 changed files with 1411 additions and 97 deletions
|
|
@ -95,7 +95,7 @@ strum = { version = "0.27", features = ["derive"] }
|
||||||
toml = "0.9"
|
toml = "0.9"
|
||||||
termcolor = "1.4.1"
|
termcolor = "1.4.1"
|
||||||
thiserror = "2"
|
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"] }
|
uuid = { version = "1.21.0", features = ["v4", "serde"] }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
profiling = "1.0.17"
|
profiling = "1.0.17"
|
||||||
|
|
|
||||||
|
|
@ -112,29 +112,41 @@ function NotConnectedPage:render()
|
||||||
BackgroundTransparency = 1,
|
BackgroundTransparency = 1,
|
||||||
ZIndex = 2,
|
ZIndex = 2,
|
||||||
}, {
|
}, {
|
||||||
Settings = e(TextButton, {
|
Settings = e(TextButton, {
|
||||||
text = "Settings",
|
text = "Settings",
|
||||||
style = "Bordered",
|
style = "Bordered",
|
||||||
transparency = self.props.transparency,
|
transparency = self.props.transparency,
|
||||||
layoutOrder = 1,
|
layoutOrder = 1,
|
||||||
onClick = self.props.onNavigateSettings,
|
onClick = self.props.onNavigateSettings,
|
||||||
}, {
|
}, {
|
||||||
Tip = e(Tooltip.Trigger, {
|
Tip = e(Tooltip.Trigger, {
|
||||||
text = "View and modify plugin settings",
|
text = "View and modify plugin settings",
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
|
}),
|
||||||
|
|
||||||
Connect = e(TextButton, {
|
Syncback = e(TextButton, {
|
||||||
text = "Sync",
|
text = "Syncback",
|
||||||
style = "Primary",
|
style = "Danger",
|
||||||
transparency = self.props.transparency,
|
transparency = self.props.transparency,
|
||||||
layoutOrder = 2,
|
layoutOrder = 2,
|
||||||
onClick = self.props.onConnect,
|
onClick = self.props.onSyncback,
|
||||||
}, {
|
}, {
|
||||||
Tip = e(Tooltip.Trigger, {
|
Tip = e(Tooltip.Trigger, {
|
||||||
text = "Connect to a Rojo sync server",
|
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", {
|
Layout = e("UIListLayout", {
|
||||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,16 @@
|
||||||
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
||||||
local Players = game:GetService("Players")
|
local Players = game:GetService("Players")
|
||||||
|
local SerializationService = game:GetService("SerializationService")
|
||||||
local ServerStorage = game:GetService("ServerStorage")
|
local ServerStorage = game:GetService("ServerStorage")
|
||||||
local RunService = game:GetService("RunService")
|
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 Rojo = script:FindFirstAncestor("Rojo")
|
||||||
local Plugin = Rojo.Plugin
|
local Plugin = Rojo.Plugin
|
||||||
local Packages = Rojo.Packages
|
local Packages = Rojo.Packages
|
||||||
|
|
@ -27,8 +35,11 @@ local ignorePlaceIds = require(Plugin.ignorePlaceIds)
|
||||||
local timeUtil = require(Plugin.timeUtil)
|
local timeUtil = require(Plugin.timeUtil)
|
||||||
local Theme = require(script.Theme)
|
local Theme = require(script.Theme)
|
||||||
|
|
||||||
|
local Http = require(Packages.Http)
|
||||||
|
|
||||||
local Page = require(script.Page)
|
local Page = require(script.Page)
|
||||||
local Notifications = require(script.Components.Notifications)
|
local Notifications = require(script.Components.Notifications)
|
||||||
|
local TextButton = require(script.Components.TextButton)
|
||||||
local Tooltip = require(script.Components.Tooltip)
|
local Tooltip = require(script.Components.Tooltip)
|
||||||
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
|
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
|
||||||
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
|
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
|
||||||
|
|
@ -604,6 +615,68 @@ function App:useRunningConnectionInfo()
|
||||||
self.setPort(port)
|
self.setPort(port)
|
||||||
end
|
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()
|
function App:startSession()
|
||||||
-- If a session is already in progress, tear it down first.
|
-- 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
|
-- 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),
|
Tooltips = e(Tooltip.Container, nil),
|
||||||
|
|
||||||
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
|
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
|
||||||
host = self.host,
|
host = self.host,
|
||||||
onHostChange = self.setHost,
|
onHostChange = self.setHost,
|
||||||
port = self.port,
|
port = self.port,
|
||||||
onPortChange = self.setPort,
|
onPortChange = self.setPort,
|
||||||
|
|
||||||
onConnect = function()
|
onConnect = function()
|
||||||
self:startSession()
|
self:startSession()
|
||||||
end,
|
end,
|
||||||
|
|
||||||
onNavigateSettings = function()
|
onSyncback = function()
|
||||||
self.backPage = AppStatus.NotConnected
|
self:setState({
|
||||||
self:setState({
|
showingSyncbackConfirm = true,
|
||||||
appStatus = AppStatus.Settings,
|
})
|
||||||
})
|
end,
|
||||||
end,
|
|
||||||
}),
|
onNavigateSettings = function()
|
||||||
|
self.backPage = AppStatus.NotConnected
|
||||||
|
self:setState({
|
||||||
|
appStatus = AppStatus.Settings,
|
||||||
|
})
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
|
||||||
ConfirmingPage = createPageElement(AppStatus.Confirming, {
|
ConfirmingPage = createPageElement(AppStatus.Confirming, {
|
||||||
confirmData = self.state.confirmData,
|
confirmData = self.state.confirmData,
|
||||||
|
|
@ -1117,22 +1196,100 @@ function App:render()
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
RojoNotifications = e("ScreenGui", {
|
RojoNotifications = e("ScreenGui", {
|
||||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||||
ResetOnSpawn = false,
|
ResetOnSpawn = false,
|
||||||
DisplayOrder = 100,
|
DisplayOrder = 100,
|
||||||
}, {
|
}, {
|
||||||
Notifications = e(Notifications, {
|
Notifications = e(Notifications, {
|
||||||
soundPlayer = self.props.soundPlayer,
|
soundPlayer = self.props.soundPlayer,
|
||||||
notifications = self.state.notifications,
|
notifications = self.state.notifications,
|
||||||
onClose = function(id)
|
onClose = function(id)
|
||||||
self:closeNotification(id)
|
self:closeNotification(id)
|
||||||
end,
|
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",
|
name = "AtlasConnection",
|
||||||
title = "Atlas: Connect/Disconnect",
|
title = "Atlas: Connect/Disconnect",
|
||||||
description = "Toggles the server for an Atlas sync session",
|
description = "Toggles the server for an Atlas sync session",
|
||||||
|
|
|
||||||
18
rojo-test/serve-tests/live_syncback/default.project.json5
Normal file
18
rojo-test/serve-tests/live_syncback/default.project.json5
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
print("old server script")
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
return "old"
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"className": "Part",
|
||||||
|
"properties": {
|
||||||
|
"Anchored": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
-- placeholder
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
return nil
|
||||||
215
src/cli/serve.rs
215
src/cli/serve.rs
|
|
@ -1,13 +1,26 @@
|
||||||
use std::{
|
use std::{
|
||||||
net::{IpAddr, Ipv4Addr},
|
collections::HashMap,
|
||||||
path::PathBuf,
|
io::Cursor,
|
||||||
|
mem::forget,
|
||||||
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
|
path::{Path, PathBuf},
|
||||||
|
process::{Command, Stdio},
|
||||||
sync::Arc,
|
sync::Arc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use memofs::Vfs;
|
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;
|
use super::resolve_path;
|
||||||
|
|
||||||
|
|
@ -35,30 +48,190 @@ impl ServeCommand {
|
||||||
pub fn run(self) -> anyhow::Result<()> {
|
pub fn run(self) -> anyhow::Result<()> {
|
||||||
let project_path = resolve_path(&self.project);
|
let project_path = resolve_path(&self.project);
|
||||||
|
|
||||||
let (vfs, critical_errors) = Vfs::new_default_with_errors();
|
let (ip, port) = {
|
||||||
|
let vfs = Vfs::new_oneshot();
|
||||||
let session = Arc::new(ServeSession::new(vfs, project_path, Some(critical_errors))?);
|
let session = ServeSession::new_oneshot(vfs, project_path.clone())?;
|
||||||
|
let ip = self
|
||||||
let ip = self
|
.address
|
||||||
.address
|
.or_else(|| session.serve_address())
|
||||||
.or_else(|| session.serve_address())
|
.unwrap_or(DEFAULT_BIND_ADDRESS.into());
|
||||||
.unwrap_or(DEFAULT_BIND_ADDRESS.into());
|
let port = self
|
||||||
|
.port
|
||||||
let port = self
|
.or_else(|| session.project_port())
|
||||||
.port
|
.unwrap_or(DEFAULT_PORT);
|
||||||
.or_else(|| session.project_port())
|
forget(session);
|
||||||
.unwrap_or(DEFAULT_PORT);
|
(ip, port)
|
||||||
|
};
|
||||||
let server = LiveServer::new(session);
|
|
||||||
|
|
||||||
|
let addr: SocketAddr = (ip, port).into();
|
||||||
let host = if ip.is_loopback() {
|
let host = if ip.is_loopback() {
|
||||||
"localhost".to_owned()
|
"localhost".to_owned()
|
||||||
} else {
|
} else {
|
||||||
ip.to_string()
|
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<WeakDom> {
|
||||||
|
let mut dom = WeakDom::new(InstanceBuilder::new("DataModel"));
|
||||||
|
let root_ref = dom.root_ref();
|
||||||
|
|
||||||
|
let mut global_ref_map: HashMap<Ref, Ref> = 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<Ref, Ref>,
|
||||||
|
) {
|
||||||
|
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<Ref, Ref>) {
|
||||||
|
let all_refs: Vec<Ref> = 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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,9 @@ use crate::{
|
||||||
web::{
|
web::{
|
||||||
interface::{
|
interface::{
|
||||||
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
|
ErrorResponse, Instance, InstanceMetadata, MessagesPacket, OpenResponse, ReadResponse,
|
||||||
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType, SubscribeMessage,
|
ServerInfoResponse, SocketPacket, SocketPacketBody, SocketPacketType,
|
||||||
WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
|
SubscribeMessage, SyncbackPayload, SyncbackRequest, WriteRequest, WriteResponse,
|
||||||
|
PROTOCOL_VERSION, SERVER_VERSION,
|
||||||
},
|
},
|
||||||
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
util::{deserialize_msgpack, msgpack, msgpack_ok, serialize_msgpack},
|
||||||
},
|
},
|
||||||
|
|
@ -122,6 +123,7 @@ fn variant_to_json(variant: &Variant) -> Option<serde_json::Value> {
|
||||||
pub async fn call(
|
pub async fn call(
|
||||||
serve_session: Arc<ServeSession>,
|
serve_session: Arc<ServeSession>,
|
||||||
mut request: Request<Incoming>,
|
mut request: Request<Incoming>,
|
||||||
|
syncback_signal: Arc<super::SyncbackSignal>,
|
||||||
) -> Response<Full<Bytes>> {
|
) -> Response<Full<Bytes>> {
|
||||||
let service = ApiService::new(serve_session);
|
let service = ApiService::new(serve_session);
|
||||||
|
|
||||||
|
|
@ -153,6 +155,9 @@ pub async fn call(
|
||||||
service.handle_api_open(request).await
|
service.handle_api_open(request).await
|
||||||
}
|
}
|
||||||
(&Method::POST, "/api/write") => service.handle_api_write(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::GET, "/api/validate-tree") => service.handle_api_validate_tree().await,
|
||||||
|
|
||||||
(_method, path) => msgpack(
|
(_method, path) => msgpack(
|
||||||
|
|
@ -162,6 +167,96 @@ pub async fn call(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn handle_api_syncback(
|
||||||
|
request: Request<Incoming>,
|
||||||
|
service: &ApiService,
|
||||||
|
syncback_signal: Arc<super::SyncbackSignal>,
|
||||||
|
) -> Response<Full<Bytes>> {
|
||||||
|
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 {
|
pub struct ApiService {
|
||||||
serve_session: Arc<ServeSession>,
|
serve_session: Arc<ServeSession>,
|
||||||
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,
|
suppressed_paths: Arc<Mutex<HashMap<PathBuf, (usize, usize)>>>,
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,36 @@ pub(crate) const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||||
/// Current protocol version, which is required to match.
|
/// Current protocol version, which is required to match.
|
||||||
pub const PROTOCOL_VERSION: u64 = 6;
|
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<u8>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<u64>,
|
||||||
|
pub services: Vec<ServiceChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Payload passed from the API handler to the serve loop after validation.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct SyncbackPayload {
|
||||||
|
pub services: Vec<ServiceChunk>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why the live server exited its accept loop.
|
||||||
|
pub enum ServerExitReason {
|
||||||
|
SyncbackRequested(SyncbackPayload),
|
||||||
|
}
|
||||||
|
|
||||||
/// Message returned by Rojo API when a change has occurred.
|
/// Message returned by Rojo API when a change has occurred.
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
|
|
|
||||||
101
src/web/mod.rs
101
src/web/mod.rs
|
|
@ -10,7 +10,7 @@ mod util;
|
||||||
|
|
||||||
use std::convert::Infallible;
|
use std::convert::Infallible;
|
||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::sync::Arc;
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use hyper::body::Incoming;
|
use hyper::body::Incoming;
|
||||||
use hyper::server::conn::http1;
|
use hyper::server::conn::http1;
|
||||||
|
|
@ -19,52 +19,103 @@ use hyper::Request;
|
||||||
use hyper_util::rt::TokioIo;
|
use hyper_util::rt::TokioIo;
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
use crate::serve_session::ServeSession;
|
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<Option<SyncbackPayload>>,
|
||||||
|
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<SyncbackPayload> {
|
||||||
|
self.payload.lock().unwrap().take()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct LiveServer {
|
pub struct LiveServer {
|
||||||
serve_session: Arc<ServeSession>,
|
serve_session: Arc<ServeSession>,
|
||||||
|
syncback_signal: Arc<SyncbackSignal>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LiveServer {
|
impl LiveServer {
|
||||||
pub fn new(serve_session: Arc<ServeSession>) -> Self {
|
pub fn new(serve_session: Arc<ServeSession>) -> 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 serve_session = Arc::clone(&self.serve_session);
|
||||||
|
let syncback_signal = Arc::clone(&self.syncback_signal);
|
||||||
|
|
||||||
let rt = Runtime::new().unwrap();
|
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();
|
let listener = TcpListener::bind(address).await.unwrap();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let (stream, _) = listener.accept().await.unwrap();
|
tokio::select! {
|
||||||
let io = TokioIo::new(stream);
|
result = listener.accept() => {
|
||||||
let serve_session = Arc::clone(&serve_session);
|
let (stream, _) = result.unwrap();
|
||||||
|
let io = TokioIo::new(stream);
|
||||||
tokio::spawn(async move {
|
|
||||||
let service = service_fn(move |req: Request<Incoming>| {
|
|
||||||
let serve_session = Arc::clone(&serve_session);
|
let serve_session = Arc::clone(&serve_session);
|
||||||
|
let syncback_signal = Arc::clone(&syncback_signal);
|
||||||
|
|
||||||
async move {
|
tokio::spawn(async move {
|
||||||
if req.uri().path().starts_with("/api") {
|
let service = service_fn(move |req: Request<Incoming>| {
|
||||||
Ok::<_, Infallible>(api::call(serve_session, req).await)
|
let serve_session = Arc::clone(&serve_session);
|
||||||
} else {
|
let syncback_signal = Arc::clone(&syncback_signal);
|
||||||
Ok::<_, Infallible>(ui::call(serve_session, req).await)
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,221 @@ fn get_port_number() -> usize {
|
||||||
port
|
port
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Build a ServiceChunk by serializing child instances into rbxm format.
|
||||||
|
pub fn make_service_chunk(
|
||||||
|
class_name: &str,
|
||||||
|
children: Vec<rbx_dom_weak::InstanceBuilder>,
|
||||||
|
) -> 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<Ref> = 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<u8> {
|
||||||
|
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<Ref, Ref> = 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> = 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<Ref> = 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<Ref, Ref>,
|
||||||
|
) {
|
||||||
|
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<u8>) -> 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<u64>,
|
||||||
|
services: Vec<librojo::web_api::ServiceChunk>,
|
||||||
|
) {
|
||||||
|
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
|
/// Extract the message cursor from a SocketPacket for use in subsequent
|
||||||
/// WebSocket subscriptions when chaining multi-step tests.
|
/// WebSocket subscriptions when chaining multi-step tests.
|
||||||
pub fn get_message_cursor(packet: &SocketPacket) -> u32 {
|
pub fn get_message_cursor(packet: &SocketPacket) -> u32 {
|
||||||
|
|
|
||||||
536
tests/tests/live_syncback.rs
Normal file
536
tests/tests/live_syncback.rs
Normal file
|
|
@ -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<u64>,
|
||||||
|
) {
|
||||||
|
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<rbx_dom_weak::types::Ref> = 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ mod clean_mode;
|
||||||
mod clean_mode_stress;
|
mod clean_mode_stress;
|
||||||
mod connected_mode;
|
mod connected_mode;
|
||||||
mod git_sync_defaults;
|
mod git_sync_defaults;
|
||||||
|
mod live_syncback;
|
||||||
mod matching_fixtures;
|
mod matching_fixtures;
|
||||||
mod serve;
|
mod serve;
|
||||||
mod syncback;
|
mod syncback;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue