Compare commits

...

35 Commits

Author SHA1 Message Date
Lucien Greathouse
836b18e68a Release 6.0.0-rc.2 2020-11-19 10:25:25 -08:00
Lucien Greathouse
046dc0d598 plugin: Fix grammar in comments a bit :) 2020-11-16 11:49:30 -08:00
Lucien Greathouse
039d92ce78 plugin: Support ClassName changes in applyPatch 2020-11-16 11:44:56 -08:00
Lucien Greathouse
2136da15d6 plugin: Ensure InstanceMap deletes existing entries before inserting 2020-11-16 11:44:39 -08:00
Lucien Greathouse
e5041d80ef plugin: Fix warning in applyPatch.lua 2020-11-11 17:11:34 -08:00
Lucien Greathouse
f66860bdfe Break apart plugin reconciler (#332)
* Start splitting apart reconciler, with tests

* Reify children in reify

* Baseline hydrate implementation

* Remove debug print

* Scaffold out diff implementation, just supporting name changes

* invariant -> error in decodeValue

* Flesh out diff and add getProperty

* Clear out top-level reconciler interface, start updating code that touches it

* Address review feedback

* Add (experimental) Selene configuration

* Add emptiness checks to PatchSet, remove unimplement invert method

* Improve descendant destruction behavior in InstanceMap

* Track instanceId on all reify errors

* Base implementation of applyPatch, returning partial patches on failure

* Change reify to accept InstanceMap and insert instances into it

* Start testing applyPatch for removals

* Add test for applyPatch adding instances successfully and not

* Add , which is just error with formatting

* Correctly use new diff and applyPatch APIs

* Improve applyPatch logging and fix field name typo

* Better debug output when reify fails

* Print out unapplied patch in debug mode

* Don't write properties if their values are not different.

This was exposed trying to sync the Rojo plugin, which
has a gigantic ModuleScript in it with the reflection
database. This workaround was present in some form in
many versions of Rojo, and I guess we still need it.

This time, I actually documented why it's here so that
I don't forget for the umpteenth time...

* Add placeholder test that needs to happen still

* Introduce easier plugin testing, write applyPatch properties test

* Delete legacy get/setCanonicalProperty files

* Fix trying to remove numbers instead of instances

* Change applyPatch to return partial patches instead of binary success

* Work towards being able to decode and apply refs

* Add helpers for PatchSet assertions

* Apply refs in reify, test all cases

* Improve diagnostics when patches fail to apply

* Stop logging when destroying untracked instances, it's ok

* Remove read before setting property in applyPatch

* Fix diff thinking all properties are changed
2020-11-11 16:30:23 -08:00
Lucien Greathouse
50f0a2bd2e Update CLI dependencies 2020-10-29 10:36:20 -07:00
Lucien Greathouse
7cd9bd383e Update to latest reflection database 2020-10-29 10:35:55 -07:00
Lucien Greathouse
45a20a1633 Remove outdated notices 2020-09-09 17:39:24 -07:00
Lucien Greathouse
ec5b3f80ef Fix theme component error regression 2020-07-03 12:21:49 -07:00
Lucien Greathouse
3b257ea87a Update repo references after Roblox move 2020-06-23 11:55:46 -07:00
Lucien Greathouse
6b82cead9c Move from rojo-rbx org to Roblox org 2020-06-22 14:14:42 -07:00
cliffchapmanrbx
79ae4c52cd Enable CLA bot (#333) 2020-06-22 14:11:53 -07:00
Lucien Greathouse
a4616cda7d Fix test place's CharacterAutoLoads value 2020-06-20 22:16:35 -07:00
Lucien Greathouse
95648361be Recreate test place, just running in Studio 2020-06-20 21:51:26 -07:00
Lucien Greathouse
0c41e9c10b Depend on latest Rojo release from Rojo 2020-06-20 21:50:25 -07:00
Lucien Greathouse
61c7ef3cb0 plugin: lazily access settings() to help with testing 2020-06-20 21:50:14 -07:00
Lucien Greathouse
65898125d0 Update changelog 2020-06-17 23:14:24 -07:00
Lucien Greathouse
da05078ff3 Load project file from VFS instead of through fs.
Fixes #320.

Previously, the root project file was loaded via methods on Project
(which do not know about the VFS) instead of through the VFS like
all other disk access.

This meant that Rojo was unable to build its own plugin because
there is no project file on the real disk, only in the VFS.
2020-06-17 23:13:29 -07:00
Lucien Greathouse
badb5c3636 Stop redundantly adding ignore paths when starting ServeSession 2020-06-17 22:54:35 -07:00
Lucien Greathouse
9453588ab1 Load built-in plugin from absolute path to make errors more apparent 2020-06-17 22:54:13 -07:00
Lucien Greathouse
4cbb3874a4 Use anyhow error reporting instead of custom 2020-06-17 14:56:09 -07:00
Lucien Greathouse
940aff7ef4 Enable globIgnorePaths by default 2020-06-17 14:42:46 -07:00
Lucien Greathouse
a3edb93273 Update Changelog 2020-06-17 14:38:39 -07:00
Lucien Greathouse
782b054b1a Pass build watch argument into Vfs 2020-06-17 14:11:48 -07:00
Lucien Greathouse
fc27b2911e Allow turning off file watching in memofs.
Also preemptively bumping version to 0.1.3 so I don't forget on next release
2020-06-17 14:06:44 -07:00
Lucien Greathouse
486b067567 Flatten snapshot middleware to be much simpler (#324)
* First take at flattening middleware for simpler code and better perf

* Undo debug prints

* Fix using wrong path in snapshot_from_vfs

* Disable some broken tests

* Re-enable (mistakenly?) disabled CSV test

* Fix some tests

* Update project file tests

* Fix benchmark
2020-06-17 13:47:09 -07:00
Lucien Greathouse
bdd1afea57 Run CI on master and PRs to master only 2020-05-20 15:30:44 -07:00
Lucien Greathouse
5ccd02939b Replace rojo-test with regular tests folder again (#323)
* Replace rojo-test with regular tests folder again

* Bump MSRV to 1.43.1
2020-05-20 15:30:05 -07:00
Lucien Greathouse
ca5b8ab309 Restore improperly tested dependency on rojo from rojo-test 2020-05-20 11:56:34 -07:00
Lucien Greathouse
9481fdd38d Add missing Cargo.lock change 2020-05-02 21:47:36 -07:00
Lucien Greathouse
56bf6d282b Stop building Rojo in rojo-test, since it doesn't work as intended 2020-05-02 21:39:13 -07:00
Lucien Greathouse
5364c9c1bc Fix Lua string escaping.
Closes #314.
2020-04-16 12:00:02 -07:00
Lucien Greathouse
a4d4beeb97 Update default place template 2020-03-30 11:34:41 -07:00
Lucien Greathouse
30a01381be Fix malformed CSV files causing crashes; fixes #310 2020-03-30 11:12:20 -07:00
135 changed files with 4745 additions and 2635 deletions

View File

@@ -1,9 +1,13 @@
name: CI
on:
pull_request:
push:
branches: ["*"]
branches:
- master
pull_request:
branches:
- master
jobs:
build:
@@ -12,7 +16,7 @@ jobs:
strategy:
matrix:
rust_version: [stable, "1.40.0"]
rust_version: [stable, "1.43.1"]
steps:
- uses: actions/checkout@v1
@@ -32,10 +36,4 @@ jobs:
run: |
cargo fmt -- --check
cargo clippy
if: matrix.rust_version == 'stable'
- name: Build (All Features)
run: cargo build --locked --verbose --all-features
- name: Run tests (All Features)
run: cargo test --locked --verbose --all-features
if: matrix.rust_version == 'stable'

8
.gitignore vendored
View File

@@ -10,9 +10,15 @@
/*.rbxl
/*.rbxlx
# Test places for the Roblox Studio Plugin
/plugin/*.rbxlx
# Roblox Studio holds 'lock' files on places
*.rbxl.lock
*.rbxlx.lock
# Snapshot files from the 'insta' Rust crate
**/*.snap.new
**/*.snap.new
# Selene generates a roblox.toml file that should not be checked in.
/roblox.toml

View File

@@ -33,7 +33,7 @@ stds.plugin = {
stds.testez = {
read_globals = {
"describe",
"it", "itFOCUS", "itSKIP",
"it", "itFOCUS", "itSKIP", "itFIXME",
"FOCUS", "SKIP", "HACK_NO_XPCALL",
"expect",
}

View File

@@ -2,6 +2,17 @@
## Unreleased Changes
## [6.0.0 Release Candidate 2](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (November 19, 2020)
* Fixed crash when malformed CSV files are put into a project. ([#310](https://github.com/rojo-rbx/rojo/issues/310))
* Fixed incorrect string escaping when producing Lua code from JSON files. ([#314](https://github.com/rojo-rbx/rojo/issues/314))
* Fixed performance issues introduced in Rojo 6.0.0-rc.1. ([#317](https://github.com/rojo-rbx/rojo/issues/317))
* Fixed `rojo plugin install` subcommand failing for everyone except Rojo developers. ([#320](https://github.com/rojo-rbx/rojo/issues/320))
* Updated default place template to take advantage of [#210](https://github.com/rojo-rbx/rojo/pull/210).
* Enabled glob ignore patterns by default and removed the `unstable_glob_ignore` feature.
* `globIgnorePaths` can be set on a project to a list of globs to ignore.
* The Rojo plugin now completes as much as it can from a patch without disconnecting. Warnings are shown in the console.
* Fixed 6.0.0-rc.1 regression causing instances that changed ClassName to instead... not change ClassName.
## [6.0.0 Release Candidate 1](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (March 29, 2020)
This release jumped from 0.6.0 to 6.0.0. Rojo has been in use in production for many users for quite a long times, and so 6.0 is a more accurate reflection of Rojo's version than a pre-1.0 version.
@@ -122,7 +133,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
## [0.5.0 Alpha 9](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.9) (April 4, 2019)
* Changed `rojo build` to use buffered I/O, which can make it up to 2x faster in some cases.
* Building [*Road Not Taken*](https://github.com/rojo-rbx/roads) to an `rbxlx` file dropped from 150ms to 70ms on my machine
* Building [*Road Not Taken*](https://github.com/LPGhatguy/roads) to an `rbxlx` file dropped from 150ms to 70ms on my machine
* Fixed `LocalizationTable` instances being made from `csv` files incorrectly interpreting empty rows and columns. ([#149](https://github.com/rojo-rbx/rojo/pull/149))
* Fixed CSV files with entries that parse as numbers causing Rojo to panic. ([#152](https://github.com/rojo-rbx/rojo/pull/152))
* Improved error messages when malformed CSV files are found in a Rojo project.
@@ -319,4 +330,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
* More robust syncing with a new reconciler
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
* Initial release, functionally very similar to [rbxfs](https://github.com/rojo-rbx/rbxfs)
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)

View File

@@ -1,5 +1,5 @@
# Contributing to the Rojo Project
Rojo is a big project and can always use more help! This guide covers all repositories underneath the [rojo-rbx organization on GitHub](https://github.com/rojo-rbx).
Rojo is a big project and can always use more help!
Some of the repositories covered are:
@@ -15,8 +15,7 @@ You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* Latest stable [Remodel](https://github.com/rojo-rbx/remodel)
* Latest stable [run-in-roblox](https://github.com/rojo-rbx/run-in-roblox)
* [Foreman](https://github.com/Roblox/foreman)
## Documentation
Documentation impacts way more people than the individual lines of code we write.

994
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package]
name = "rojo"
version = "6.0.0-rc.1"
version = "6.0.0-rc.2"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
@@ -23,15 +23,11 @@ panic = "abort"
[features]
default = []
# Turn on support for specifying glob ignore path rules in the project format.
unstable_glob_ignore_paths = []
# Enable this feature to live-reload assets from the web UI.
dev_live_assets = []
[workspace]
members = [
"rojo-test",
"rojo-insta-ext",
"clibrojo",
"memofs",
@@ -39,7 +35,6 @@ members = [
default-members = [
".",
"rojo-test",
"rojo-insta-ext",
"memofs",
]
@@ -97,7 +92,7 @@ uuid = { version = "0.8.1", features = ["v4", "serde"] }
winreg = "0.6.2"
[build-dependencies]
memofs = { version = "0.1.0", path = "memofs" }
memofs = { version = "0.1.3", path = "memofs" }
anyhow = "1.0.27"
bincode = "1.2.1"

View File

@@ -48,7 +48,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome!
Rojo supports Rust 1.40.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
Rojo supports Rust 1.43.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -4,27 +4,19 @@
"$className": "DataModel",
"ReplicatedStorage": {
"$className": "ReplicatedStorage",
"Common": {
"$path": "src/shared"
}
},
"ServerScriptService": {
"$className": "ServerScriptService",
"Server": {
"$path": "src/server"
}
},
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"Client": {
"$path": "src/client"
}
@@ -32,7 +24,6 @@
},
"Workspace": {
"$className": "Workspace",
"$properties": {
"FilteringEnabled": true
},
@@ -60,7 +51,6 @@
}
},
"Lighting": {
"$className": "Lighting",
"$properties": {
"Ambient": [
0,
@@ -74,16 +64,9 @@
}
},
"SoundService": {
"$className": "SoundService",
"$properties": {
"RespectFilteringEnabled": true
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
}
}
}

View File

@@ -35,6 +35,7 @@ fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
let options = BuildCommand {
project: input,
watch: false,
output,
};

3
foreman.toml Normal file
View File

@@ -0,0 +1,3 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "6.0.0-rc.1" }
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }

View File

@@ -1,6 +1,10 @@
# memofs Changelog
## Unreleased Changes
* Added `set_watch_enabled` to `Vfs` and `VfsLock` to allow turning off file watching.
## 0.1.2 (2020-03-29)
* `VfsSnapshot` now implements Serde's `Serialize` and `Deserialize` traits.
## 0.1.1 (2020-03-18)
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.

View File

@@ -1,7 +1,7 @@
[package]
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.1.2"
version = "0.1.3"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
readme = "README.md"

View File

@@ -19,4 +19,4 @@ memofs is currently an unstable minimum viable library. Its primary consumer is
* Configurable caching (write-through, write-around, write-back)
## License
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.

View File

@@ -140,13 +140,18 @@ pub enum VfsEvent {
/// the public interfaces to this type.
struct VfsInner {
backend: Box<dyn VfsBackend>,
watch_enabled: bool,
}
impl VfsInner {
fn read<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<Vec<u8>>> {
let path = path.as_ref();
let contents = self.backend.read(path)?;
self.backend.watch(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
Ok(Arc::new(contents))
}
@@ -159,7 +164,11 @@ impl VfsInner {
fn read_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<ReadDir> {
let path = path.as_ref();
let dir = self.backend.read_dir(path)?;
self.backend.watch(path)?;
if self.watch_enabled {
self.backend.watch(path)?;
}
Ok(dir)
}
@@ -215,6 +224,7 @@ impl Vfs {
pub fn new<B: VfsBackend>(backend: B) -> Self {
let lock = VfsInner {
backend: Box::new(backend),
watch_enabled: true,
};
Self {
@@ -229,6 +239,16 @@ impl Vfs {
}
}
/// Turns automatic file watching on or off. Enabled by default.
///
/// Turning off file watching may be useful for single-use cases, especially
/// on platforms like macOS where registering file watches has significant
/// performance cost.
pub fn set_watch_enabled(&self, enabled: bool) {
let mut inner = self.inner.lock().unwrap();
inner.watch_enabled = enabled;
}
/// Read a file from the VFS, or the underlying backend if it isn't
/// resident.
///
@@ -318,6 +338,15 @@ pub struct VfsLock<'a> {
}
impl VfsLock<'_> {
/// Turns automatic file watching on or off. Enabled by default.
///
/// Turning off file watching may be useful for single-use cases, especially
/// on platforms like macOS where registering file watches has significant
/// performance cost.
pub fn set_watch_enabled(&mut self, enabled: bool) {
self.inner.watch_enabled = enabled;
}
/// Read a file from the VFS, or the underlying backend if it isn't
/// resident.
///

10
perf-test.sh Normal file
View File

@@ -0,0 +1,10 @@
#!/bin/sh
set -e
cargo build --release
echo "Known good:"
time rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx
echo "Current:"
time ./target/release/rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx

View File

@@ -53,4 +53,8 @@ function Log.warn(template, ...)
end
end
function Log.error(template, ...)
error(Fmt.fmt(template, ...))
end
return Log

View File

@@ -1,2 +1,2 @@
# rbx\_dom\_lua
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx\_dom\_weak and friends.
# rbx_dom_lua
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx_dom_weak and friends.

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,16 @@
name. This isn't exactly best practice.
]]
local Studio = settings():GetService("Studio")
-- Studio does not exist outside Roblox Studio, so we'll lazily initialize it
-- when possible.
local _Studio
local function getStudio()
if _Studio == nil then
_Studio = settings():GetService("Studio")
end
return _Studio
end
local Rojo = script:FindFirstAncestor("Rojo")
@@ -52,7 +61,7 @@ local StudioProvider = Roact.Component:extend("StudioProvider")
-- Pull the current theme from Roblox Studio and update state with it.
function StudioProvider:updateTheme()
local studioTheme = Studio.Theme
local studioTheme = getStudio().Theme
if studioTheme.Name == "Light" then
self:setState({
@@ -82,7 +91,7 @@ function StudioProvider:render()
end
function StudioProvider:didMount()
self.connection = Studio.ThemeChanged:Connect(function()
self.connection = getStudio().ThemeChanged:Connect(function()
self:updateTheme()
end)
end

View File

@@ -5,7 +5,7 @@ local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
return strict("Config", {
isDevBuild = isDevBuild,
codename = "Epiphany",
version = {6, 0, 0, "-rc.1"},
version = {6, 0, 0, "-rc.2"},
expectedServerVersionString = "6.0 or newer",
protocolVersion = 3,
defaultHost = "localhost",

View File

@@ -33,6 +33,16 @@ function InstanceMap.new(onInstanceChanged)
return setmetatable(self, InstanceMap)
end
function InstanceMap:size()
local size = 0
for _ in pairs(self.fromIds) do
size = size + 1
end
return size
end
--[[
Disconnect all connections and release all instance references.
]]
@@ -69,6 +79,9 @@ function InstanceMap:__fmtDebug(output)
end
function InstanceMap:insert(id, instance)
self:removeId(id)
self:removeInstance(instance)
self.fromIds[id] = instance
self.fromInstances[instance] = id
self:__connectSignals(instance)
@@ -81,8 +94,6 @@ function InstanceMap:removeId(id)
self:__disconnectSignals(instance)
self.fromIds[id] = nil
self.fromInstances[instance] = nil
else
Log.warn("Attempted to remove nonexistant ID {}", id)
end
end
@@ -93,8 +104,6 @@ function InstanceMap:removeInstance(instance)
if id ~= nil then
self.fromInstances[instance] = nil
self.fromIds[id] = nil
else
Log.warn("Attempted to remove nonexistant instance {}", instance)
end
end
@@ -102,10 +111,14 @@ function InstanceMap:destroyInstance(instance)
local id = self.fromInstances[instance]
if id ~= nil then
self:destroyId(id)
else
Log.warn("Attempted to destroy untracked instance {}", instance)
self:removeId(id)
end
for _, descendantInstance in ipairs(instance:GetDescendants()) do
self:removeInstance(descendantInstance)
end
instance:Destroy()
end
function InstanceMap:destroyId(id)
@@ -113,21 +126,11 @@ function InstanceMap:destroyId(id)
self:removeId(id)
if instance ~= nil then
local descendantsToDestroy = {}
for otherInstance in pairs(self.fromInstances) do
if otherInstance:IsDescendantOf(instance) then
table.insert(descendantsToDestroy, otherInstance)
end
end
for _, otherInstance in ipairs(descendantsToDestroy) do
self:removeInstance(otherInstance)
for _, descendantInstance in ipairs(instance:GetDescendants()) do
self:removeInstance(descendantInstance)
end
instance:Destroy()
else
Log.warn("Attempted to destroy nonexistant ID {}", id)
end
end

View File

@@ -16,10 +16,169 @@ PatchSet.validate = t.interface({
})
--[[
Invert the given PatchSet using the given instance map.
Create a new, empty PatchSet.
]]
function PatchSet.invert(patchSet, instanceMap)
error("not yet implemented", 2)
function PatchSet.newEmpty()
return {
removed = {},
added = {},
updated = {},
}
end
--[[
Tells whether the given PatchSet is empty.
]]
function PatchSet.isEmpty(patchSet)
return next(patchSet.removed) == nil and
next(patchSet.added) == nil and
next(patchSet.updated) == nil
end
--[[
Tells whether the given PatchSet has any remove operations.
]]
function PatchSet.hasRemoves(patchSet)
return next(patchSet.removed) ~= nil
end
--[[
Tells whether the given PatchSet has any add operations.
]]
function PatchSet.hasAdditions(patchSet)
return next(patchSet.added) ~= nil
end
--[[
Tells whether the given PatchSet has any update operations.
]]
function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil
end
--[[
Merge multiple PatchSet objects into the given PatchSet.
]]
function PatchSet.assign(target, ...)
for i = 1, select("#", ...) do
local sourcePatch = select(i, ...)
for _, removed in ipairs(sourcePatch.removed) do
table.insert(target.removed, removed)
end
for id, added in pairs(sourcePatch.added) do
target.added[id] = added
end
for _, update in ipairs(sourcePatch.updated) do
table.insert(target.updated, update)
end
end
return target
end
--[[
Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users.
]]
function PatchSet.humanSummary(instanceMap, patchSet)
local statements = {}
for _, idOrInstance in ipairs(patchSet.removed) do
local instance, id
if type(idOrInstance) == "string" then
id = idOrInstance
instance = instanceMap.fromIds[id]
else
instance = idOrInstance
id = instanceMap.fromInstances[instance]
end
if instance ~= nil then
table.insert(statements, string.format("- Delete instance %s", instance:GetFullName()))
else
table.insert(statements, string.format("- Delete instance with ID %s", id))
end
end
local additionsMentioned = {}
local function addAllDescendents(virtualInstance)
additionsMentioned[virtualInstance.Id] = true
for _, childId in ipairs(virtualInstance.Children) do
addAllDescendents(patchSet.added[childId])
end
end
for id, addition in pairs(patchSet.added) do
if additionsMentioned[id] then
continue
end
local virtualInstance = addition
while true do
if virtualInstance.Parent == nil then
break
end
local virtualParent = patchSet.added[virtualInstance.Parent]
if virtualParent == nil then
break
end
virtualInstance = virtualParent
end
local parentDisplayName = "nil (how strange!)"
if virtualInstance.Parent ~= nil then
local parent = instanceMap.fromIds[virtualInstance.Parent]
if parent ~= nil then
parentDisplayName = parent:GetFullName()
end
end
table.insert(statements, string.format(
"- Add instance %q (ClassName %q) to %s",
virtualInstance.Name, virtualInstance.ClassName, parentDisplayName))
end
for _, update in ipairs(patchSet.updated) do
local updatedProperties = {}
if update.changedMetadata ~= nil then
table.insert(updatedProperties, "Rojo's Metadata")
end
if update.changedName ~= nil then
table.insert(updatedProperties, "Name")
end
if update.changedClassName ~= nil then
table.insert(updatedProperties, "ClassName")
end
for name in pairs(update.changedProperties) do
table.insert(updatedProperties, name)
end
local instance = instanceMap.fromIds[update.id]
local displayName
if instance ~= nil then
displayName = instance:GetFullName()
else
displayName = "[unknown instance]"
end
table.insert(statements, string.format(
"- Update properties on %s: %s",
displayName, table.concat(updatedProperties, ",")))
end
return table.concat(statements, "\n")
end
return PatchSet

View File

@@ -1,389 +0,0 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local RbxDom = require(script.Parent.Parent.RbxDom)
local t = require(script.Parent.Parent.t)
local Types = require(script.Parent.Types)
local invariant = require(script.Parent.invariant)
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
local PatchSet = require(script.Parent.PatchSet)
--[[
Attempt to safely set the parent of an instance.
This function will always succeed, even if the actual set failed. This is
important for some types like services that will throw even if their current
parent is already set to the requested parent.
TODO: See if we can eliminate this by being more nuanced with property
assignment?
]]
local function safeSetParent(instance, newParent)
pcall(function()
instance.Parent = newParent
end)
end
--[[
Similar to setting Parent, some instances really don't like being renamed.
TODO: Should we be throwing away these results or can we be more careful?
]]
local function safeSetName(instance, name)
pcall(function()
instance.Name = name
end)
end
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
}
return setmetatable(self, Reconciler)
end
--[[
See Reconciler:__hydrateInternal().
]]
function Reconciler:hydrate(apiInstances, id, instance)
local hydratePatch = {
removed = {},
added = {},
updated = {},
}
self:__hydrateInternal(apiInstances, id, instance, hydratePatch)
return hydratePatch
end
--[[
Applies a patch to the Roblox DOM using the reconciler's internal state.
TODO: This function might only apply some of the patch in the future and
require content negotiation with the Rojo server to handle types that aren't
editable by scripts.
]]
local applyPatchSchema = Types.ifEnabled(t.tuple(
PatchSet.validate
))
function Reconciler:applyPatch(patch)
assert(applyPatchSchema(patch))
for _, removedIdOrInstance in ipairs(patch.removed) do
local removedInstance
if Types.RbxId(removedIdOrInstance) then
-- If this value is an ID, it's assumed to be an instance that the
-- Rojo server knows about.
removedInstance = self.__instanceMap.fromIds[removedIdOrInstance]
self.__instanceMap:removeId(removedIdOrInstance)
end
-- If this entry was an ID that we didn't know about, removedInstance
-- will be nil, which we guard against in case of minor tree desync.
if removedInstance ~= nil then
-- Ensure that if any descendants are tracked by Rojo, that we
-- properly un-track them.
for _, descendantInstance in ipairs(removedInstance:GetDescendants()) do
self.__instanceMap:removeInstance(descendantInstance)
end
removedInstance:Destroy()
end
end
-- TODO: This loop assumes that apiInstance.ParentId is never nil. The Rojo
-- plugin can't create a new top-level DataModel anyways, so this should
-- only be violated in cases that are already erroneous.
for id, apiInstance in pairs(patch.added) do
if self.__instanceMap.fromIds[id] == nil then
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[apiInstance.Parent] ~= nil do
id = apiInstance.Parent
apiInstance = patch.added[id]
end
local parentInstance = self.__instanceMap.fromIds[apiInstance.Parent]
if parentInstance == nil then
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
apiInstance.Parent,
self.__instanceMap
)
end
self:__reifyInstance(patch.added, id, parentInstance)
end
end
for _, update in ipairs(patch.updated) do
local instance = self.__instanceMap.fromIds[update.id]
if instance == nil then
invariant(
"Cannot update an instance that does not exist in the reconciler's state.\nInstance {}\nState: {:#?}",
update.id,
self.__instanceMap
)
end
if update.changedClassName ~= nil then
error("TODO: Support changing class name by destroying + recreating instance.")
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
print("TODO: Support changing metadata, if necessary.")
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
-- TODO: Gracefully handle this error instead?
assert(setCanonicalProperty(instance, propertyName, self:__decodeApiValue(propertyValue)))
end
end
end
end
--[[
Transforms a value into one that can be sent over the network back to the
Rojo server.
This operation can fail, and so it returns bool, value.
]]
function Reconciler:encodeApiValue(value)
if typeof(value) == "string" then
return true, {
Type = "String",
Value = value,
}
end
return false
end
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
function Reconciler:__decodeApiValue(apiValue)
assert(Types.ApiValue(apiValue))
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if apiValue.Type == "Ref" then
-- TODO: This ref could be pointing at an instance we haven't created
-- yet!
return self.__instanceMap.fromIds[apiValue.Value]
end
local success, decodedValue = RbxDom.EncodedValue.decode(apiValue)
if not success then
error(decodedValue, 2)
end
return decodedValue
end
--[[
Constructs an instance from an ApiInstance without any of its children.
]]
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
Types.ApiInstance
))
function Reconciler:__reifySingleInstance(apiInstance)
assert(reifySingleInstanceSchema(apiInstance))
-- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, apiInstance.ClassName)
if not ok then
return false, instance
end
-- TODO: When can setting Name fail here?
safeSetName(instance, apiInstance.Name)
for key, value in pairs(apiInstance.Properties) do
setCanonicalProperty(instance, key, self:__decodeApiValue(value))
end
return true, instance
end
--[[
Construct an instance and all of its descendants, parent it to the given
instance, and insert it into the reconciler's internal state.
]]
local reifyInstanceSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance
))
function Reconciler:__reifyInstance(apiInstances, id, parentInstance)
assert(reifyInstanceSchema(apiInstances, id, parentInstance))
local apiInstance = apiInstances[id]
local ok, instance = self:__reifySingleInstance(apiInstance)
-- TODO: Propagate this error upward to handle it elsewhere?
if not ok then
error(("Couldn't create an instance of type %q, a child of %s"):format(
apiInstance.ClassName,
parentInstance:GetFullName()
))
end
self.__instanceMap:insert(id, instance)
for _, childId in ipairs(apiInstance.Children) do
self:__reifyInstance(apiInstances, childId, instance)
end
safeSetParent(instance, parentInstance)
return instance
end
--[[
Populates the reconciler's internal state, maps IDs to instances that the
Rojo plugin knows about, and generates a patch that would update the Roblox
tree to match Rojo's view of the tree.
]]
local hydrateSchema = Types.ifEnabled(t.tuple(
t.map(Types.RbxId, Types.VirtualInstance),
Types.RbxId,
t.Instance,
PatchSet.validate
))
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))
self.__instanceMap:insert(id, instance)
local apiInstance = apiInstances[id]
local function markIdAdded(id)
local apiInstance = apiInstances[id]
hydratePatch.added[id] = apiInstance
for _, childId in ipairs(apiInstance.Children) do
markIdAdded(childId)
end
end
local changedName = nil
local changedProperties = {}
if apiInstance.Name ~= instance.Name then
changedName = apiInstance.Name
end
for propertyName, virtualValue in pairs(apiInstance.Properties) do
local success, existingValue = getCanonicalProperty(instance, propertyName)
if success then
local decodedValue = self:__decodeApiValue(virtualValue)
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
end
end
-- If any properties differed from the virtual instance we read, add it to
-- the hydrate patch so that we can catch up.
if changedName ~= nil or next(changedProperties) ~= nil then
table.insert(hydratePatch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end
local existingChildren = instance:GetChildren()
-- For each existing child, we'll track whether it's been paired with an
-- instance that the Rojo server knows about.
local isExistingChildVisited = {}
for i = 1, #existingChildren do
isExistingChildVisited[i] = false
end
for _, childId in ipairs(apiInstance.Children) do
local apiChild = apiInstances[childId]
local childInstance
for childIndex, instance in ipairs(existingChildren) do
if not isExistingChildVisited[childIndex] then
-- We guard accessing Name and ClassName in order to avoid
-- tripping over children of DataModel that Rojo won't have
-- permissions to access at all.
local ok, name, className = pcall(function()
return instance.Name, instance.ClassName
end)
-- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
if ok and name == apiChild.Name and className == apiChild.ClassName then
childInstance = instance
isExistingChildVisited[childIndex] = true
break
end
end
end
if childInstance ~= nil then
-- We found an instance that matches the instance from the API, yay!
self:__hydrateInternal(apiInstances, childId, childInstance, hydratePatch)
else
markIdAdded(childId)
end
end
-- Any unvisited children at this point aren't known by Rojo and we can
-- destroy them unless the user has explicitly asked us to preserve children
-- of this instance.
local shouldClearUnknown = self:__shouldClearUnknownChildren(apiInstance)
if shouldClearUnknown then
for childIndex, visited in ipairs(isExistingChildVisited) do
if not visited then
table.insert(hydratePatch.removed, existingChildren[childIndex])
end
end
end
end
function Reconciler:__shouldClearUnknownChildren(apiInstance)
if apiInstance.Metadata ~= nil then
return not apiInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
return Reconciler

View File

@@ -0,0 +1,37 @@
--[[
Defines the errors that can be returned by the reconciler.
]]
local Fmt = require(script.Parent.Parent.Parent.Fmt)
local Error = {}
local function makeVariant(name)
Error[name] = setmetatable({}, {
__tostring = function()
return "Error." .. name
end,
})
end
makeVariant("CannotCreateInstance")
makeVariant("CannotDecodeValue")
makeVariant("LackingPropertyPermissions")
makeVariant("OtherPropertyError")
makeVariant("RefDidNotExist")
makeVariant("UnknownProperty")
makeVariant("UnreadableProperty")
makeVariant("UnwritableProperty")
function Error.new(kind, details)
return setmetatable({
kind = kind,
details = details,
}, Error)
end
function Error:__tostring()
return Fmt.fmt("Error({}): {:#?}", self.kind, self.details)
end
return Error

View File

@@ -0,0 +1,200 @@
--[[
Apply a patch to the DOM. Returns any portions of the patch that weren't
possible to apply.
Patches can come from the server or be generated by the client.
]]
local Log = require(script.Parent.Parent.Parent.Log)
local PatchSet = require(script.Parent.Parent.PatchSet)
local Types = require(script.Parent.Parent.Types)
local invariant = require(script.Parent.Parent.invariant)
local decodeValue = require(script.Parent.decodeValue)
local reify = require(script.Parent.reify)
local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch)
-- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty()
for _, removedIdOrInstance in ipairs(patch.removed) do
if Types.RbxId(removedIdOrInstance) then
instanceMap:destroyId(removedIdOrInstance)
else
instanceMap:destroyInstance(removedIdOrInstance)
end
end
for id, virtualInstance in pairs(patch.added) do
if instanceMap.fromIds[id] ~= nil then
-- This instance already exists. We might've already added it in a
-- previous iteration of this loop, or maybe this patch was not
-- supposed to list this instance.
--
-- It's probably fine, right?
continue
end
-- Find the first ancestor of this instance that is marked for an
-- addition.
--
-- This helps us make sure we only reify each instance once, and we
-- start from the top.
while patch.added[virtualInstance.Parent] ~= nil do
id = virtualInstance.Parent
virtualInstance = patch.added[id]
end
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
if parentInstance == nil then
-- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all?
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
virtualInstance.Parent,
instanceMap
)
end
local failedToReify = reify(instanceMap, patch.added, id, parentInstance)
if not PatchSet.isEmpty(failedToReify) then
Log.debug("Failed to reify as part of applying a patch: {}", failedToReify)
PatchSet.assign(unappliedPatch, failedToReify)
end
end
for _, update in ipairs(patch.updated) do
local instance = instanceMap.fromIds[update.id]
if instance == nil then
-- We can't update an instance that doesn't exist.
table.insert(unappliedPatch.updated, update)
continue
end
-- Track any part of this update that could not be applied.
local unappliedUpdate = {
id = update.id,
changedProperties = {},
}
local partiallyApplied = false
-- If the instance's className changed, we have a bumpy ride ahead while
-- we recreate this instance and move all of its children into the new
-- version atomically...ish.
if update.changedClassName ~= nil then
-- If the instance's name also changed, we'll do it here, since this
-- branch will skip the rest of the loop iteration.
local newName = update.changedName or instance.Name
-- TODO: When changing between instances that have similar sets of
-- properties, like between an ImageLabel and an ImageButton, we
-- should preserve all of the properties that are shared between the
-- two classes unless they're changed as part of this patch. This is
-- similar to how "class changer" Studio plugins work.
--
-- For now, we'll only apply properties that are mentioned in this
-- update. Patches with changedClassName set only occur in specific
-- circumstances, usually between Folder and ModuleScript instances.
-- While this may result in some issues, like not preserving the
-- "Archived" property, a robust solution is sufficiently
-- complicated that we're pushing it off for now.
local newProperties = update.changedProperties
-- If the instance's ClassName changed, we'll kick into reify to
-- create this instance. We'll handle moving all of children between
-- the instances after the new one is created.
local mockVirtualInstance = {
Id = update.id,
Name = newName,
ClassName = update.changedClassName,
Properties = newProperties,
Children = {},
}
local mockAdded = {
[update.id] = mockVirtualInstance,
}
local failedToReify = reify(instanceMap, mockAdded, update.id, instance.Parent)
local newInstance = instanceMap.fromIds[update.id]
-- Some parts of reify may have failed, but this is not necessarily
-- critical. If the instance wasn't recreated or has the wrong Name,
-- we'll consider our attempt a failure.
if instance == newInstance or newInstance.Name ~= newName then
table.insert(unappliedPatch.updated, update)
continue
end
-- Here are the non-critical failures. We know that the instance
-- succeeded in creating and that assigning Name did not fail, but
-- other property assignments might've failed.
if not PatchSet.isEmpty(failedToReify) then
PatchSet.assign(unappliedPatch, failedToReify)
end
-- Watch out, this is the scary part! Move all of the children of
-- instance into newInstance.
--
-- TODO: If this fails part way through, should we move everything
-- back? For now, we assume that moving things will not fail.
for _, child in ipairs(instance:GetChildren()) do
child.Parent = newInstance
end
-- See you later, original instance.
--
-- TODO: Can this fail? Some kinds of instance may not appreciate
-- being destroyed, like services.
instance:Destroy()
-- This completes your rebuilding a plane mid-flight safety
-- instruction. Please sit back, relax, and enjoy your flight.
continue
end
if update.changedName ~= nil then
instance.Name = update.changedName
end
if update.changedMetadata ~= nil then
-- TODO: Support changing metadata. This will become necessary when
-- Rojo persistently tracks metadata for each instance in order to
-- remove extra instances.
unappliedUpdate.changedMetadata = update.changedMetadata
partiallyApplied = true
end
if update.changedProperties ~= nil then
for propertyName, propertyValue in pairs(update.changedProperties) do
local ok, decodedValue = decodeValue(propertyValue, instanceMap)
if not ok then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
continue
end
local ok = setProperty(instance, propertyName, decodedValue)
if not ok then
unappliedUpdate.changedProperties[propertyName] = propertyValue
partiallyApplied = true
end
end
end
if partiallyApplied then
table.insert(unappliedPatch.updated, unappliedUpdate)
end
end
return unappliedPatch
end
return applyPatch

View File

@@ -0,0 +1,198 @@
return function()
local applyPatch = require(script.Parent.applyPatch)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet)
local dummy = Instance.new("Folder")
local function wasDestroyed(instance)
-- If an instance was destroyed, its parent property is locked.
local ok = pcall(function()
local oldParent = instance.Parent
instance.Parent = dummy
instance.Parent = oldParent
end)
return not ok
end
it("should return an empty patch if given an empty patch", function()
local patch = applyPatch(InstanceMap.new(), PatchSet.newEmpty())
assert(PatchSet.isEmpty(patch), "expected remaining patch to be empty")
end)
it("should destroy instances listed for remove", function()
local root = Instance.new("Folder")
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.removed, child)
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
assert(not wasDestroyed(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed")
instanceMap:stop()
end)
it("should destroy IDs listed for remove", function()
local root = Instance.new("Folder")
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.removed, "CHILD")
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(instanceMap:size()).to.equal(1)
assert(not wasDestroyed(root), "expected root to be left alone")
assert(wasDestroyed(child), "expected child to be destroyed")
instanceMap:stop()
end)
it("should add instances to the DOM", function()
-- Many of the details of this functionality are instead covered by
-- tests on reify, not here.
local root = Instance.new("Folder")
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
local patch = PatchSet.newEmpty()
patch.added["CHILD"] = {
Id = "CHILD",
ClassName = "Model",
Name = "Child",
Parent = "ROOT",
Children = {"GRANDCHILD"},
Properties = {},
}
patch.added["GRANDCHILD"] = {
Id = "GRANDCHILD",
ClassName = "Part",
Name = "Grandchild",
Parent = "CHILD",
Children = {},
Properties = {},
}
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(instanceMap:size()).to.equal(3)
local child = root:FindFirstChild("Child")
expect(child).to.be.ok()
expect(child.ClassName).to.equal("Model")
expect(child).to.equal(instanceMap.fromIds["CHILD"])
local grandchild = child:FindFirstChild("Grandchild")
expect(grandchild).to.be.ok()
expect(grandchild.ClassName).to.equal("Part")
expect(grandchild).to.equal(instanceMap.fromIds["GRANDCHILD"])
end)
it("should return unapplied additions when instances cannot be created", function()
local root = Instance.new("Folder")
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
local patch = PatchSet.newEmpty()
patch.added["OOPSIE"] = {
Id = "OOPSIE",
-- Hopefully Roblox never makes an instance with this ClassName.
ClassName = "UH OH",
Name = "FUBAR",
Parent = "ROOT",
Children = {},
Properties = {},
}
local unapplied = applyPatch(instanceMap, patch)
expect(unapplied.added["OOPSIE"]).to.equal(patch.added["OOPSIE"])
expect(instanceMap:size()).to.equal(1)
expect(#root:GetChildren()).to.equal(0)
end)
it("should apply property changes to instances", function()
local value = Instance.new("StringValue")
value.Value = "HELLO"
local instanceMap = InstanceMap.new()
instanceMap:insert("VALUE", value)
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "VALUE",
changedProperties = {
Value = {
Type = "String",
Value = "WORLD",
},
},
})
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
expect(value.Value).to.equal("WORLD")
end)
it("should recreate instances when changedClassName is set, preserving children", function()
local root = Instance.new("Folder")
root.Name = "Initial Root Name"
local child = Instance.new("Folder")
child.Name = "Child"
child.Parent = root
local instanceMap = InstanceMap.new()
instanceMap:insert("ROOT", root)
instanceMap:insert("CHILD", child)
local patch = PatchSet.newEmpty()
table.insert(patch.updated, {
id = "ROOT",
changedName = "Updated Root Name",
changedClassName = "StringValue",
changedProperties = {
Value = {
Type = "String",
Value = "I am Root",
},
},
})
local unapplied = applyPatch(instanceMap, patch)
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
local newRoot = instanceMap.fromIds["ROOT"]
assert(newRoot ~= root, "expected instance to be recreated")
expect(newRoot.ClassName).to.equal("StringValue")
expect(newRoot.Name).to.equal("Updated Root Name")
expect(newRoot.Value).to.equal("I am Root")
local newChild = newRoot:FindFirstChild("Child")
assert(newChild ~= nil, "expected child to be present")
assert(newChild == child, "expected child to be preserved")
end)
end

View File

@@ -0,0 +1,35 @@
--[[
Transforms a value encoded by rbx_dom_weak on the server side into a value
usable by Rojo's reconciler, potentially using RbxDom.
]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
local Error = require(script.Parent.Error)
local function decodeValue(virtualValue, instanceMap)
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
if virtualValue.Type == "Ref" then
local instance = instanceMap.fromIds[virtualValue.Value]
if instance ~= nil then
return true, instance
else
return false, Error.new(Error.RefDidNotExist, {
virtualValue = virtualValue,
})
end
end
local ok, decodedValue = RbxDom.EncodedValue.decode(virtualValue)
if not ok then
return false, Error.new(Error.CannotDecodeValue, {
virtualValue = virtualValue,
innerError = decodedValue,
})
end
return true, decodedValue
end
return decodeValue

View File

@@ -0,0 +1,148 @@
--[[
Defines the process for diffing a virtual DOM and the real DOM to compute a
patch that can be later applied.
]]
local Log = require(script.Parent.Parent.Parent.Log)
local invariant = require(script.Parent.Parent.invariant)
local getProperty = require(script.Parent.getProperty)
local Error = require(script.Parent.Error)
local decodeValue = require(script.Parent.decodeValue)
local function isEmpty(table)
return next(table) == nil
end
local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances
else
return true
end
end
local function diff(instanceMap, virtualInstances, rootId)
local patch = {
removed = {},
added = {},
updated = {},
}
-- Add a virtual instance and all of its descendants to the patch, marked as
-- being added.
local function markIdAdded(id)
local virtualInstance = virtualInstances[id]
patch.added[id] = virtualInstance
for _, childId in ipairs(virtualInstance.Children) do
markIdAdded(childId)
end
end
-- Internal recursive kernel for diffing an instance with the given ID.
local function diffInternal(id)
local virtualInstance = virtualInstances[id]
local instance = instanceMap.fromIds[id]
if virtualInstance == nil then
invariant("Cannot diff an instance not present in virtualInstances\nID: {}", id)
end
if instance == nil then
invariant("Cannot diff an instance not present in InstanceMap\nID: {}", id)
end
if virtualInstance.ClassName ~= instance.ClassName then
error("unimplemented: support changing ClassName")
end
local changedName = nil
if virtualInstance.Name ~= instance.Name then
changedName = virtualInstance.Name
end
local changedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
local ok, existingValueOrErr = getProperty(instance, propertyName)
if ok then
local existingValue = existingValueOrErr
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then
if existingValue ~= decodedValue then
changedProperties[propertyName] = virtualValue
end
else
Log.warn("Failed to decode property of type {}", virtualValue.Type)
end
else
local err = existingValueOrErr
if err.kind == Error.UnknownProperty then
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
elseif err.kind == Error.UnreadableProperty then
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
else
return false, err
end
end
end
if changedName ~= nil or not isEmpty(changedProperties) then
table.insert(patch.updated, {
id = id,
changedName = changedName,
changedClassName = nil,
changedProperties = changedProperties,
changedMetadata = nil,
})
end
-- Traverse the list of children in the DOM. Any instance that has no
-- corresponding virtual instance should be removed. Any instance that
-- does have a corresponding virtual instance is recursively diffed.
for _, childInstance in ipairs(instance:GetChildren()) do
local childId = instanceMap.fromInstances[childInstance]
if childId == nil then
-- This is an existing instance not present in the virtual DOM.
-- We can mark it for deletion unless the user has asked us not
-- to delete unknown stuff.
if shouldDeleteUnknownInstances(virtualInstance) then
table.insert(patch.removed, childInstance)
end
else
local ok, err = diffInternal(childId)
if not ok then
return false, err
end
end
end
-- Traverse the list of children in the virtual DOM. Any virtual
-- instance that has no corresponding real instance should be created.
for _, childId in ipairs(virtualInstance.Children) do
local childInstance = instanceMap.fromIds[childId]
if childInstance == nil then
-- This instance is present in the virtual DOM, but doesn't
-- exist in the real DOM.
markIdAdded(childId)
end
end
return true
end
local ok, err = diffInternal(rootId)
if not ok then
return false, err
end
return true, patch
end
return diff

View File

@@ -0,0 +1,292 @@
return function()
local Log = require(script.Parent.Parent.Parent.Log)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet)
local diff = require(script.Parent.diff)
local function isEmpty(table)
return next(table) == nil, "Table was not empty"
end
local function size(dict)
local len = 0
for _ in pairs(dict) do
len = len + 1
end
return len
end
it("should generate an empty patch for empty instances", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Some Name",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
rootInstance.Name = "Some Name"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
it("should generate a patch with a changed name", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Some Name",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
expect(#patch.updated).to.equal(1)
local update = patch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedName).to.equal("Some Name")
assert(isEmpty(update.changedProperties))
end)
it("should generate a patch with a changed property", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("StringValue")
rootInstance.Value = "Initial Value"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
expect(#patch.updated).to.equal(1)
local update = patch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedName).to.equal(nil)
expect(size(update.changedProperties)).to.equal(1)
local patchProperty = update.changedProperties["Value"]
expect(patchProperty).to.be.a("table")
expect(patchProperty.Type).to.equal("String")
expect(patchProperty.Value).to.equal("Hello, world!")
end)
it("should generate an empty patch if no properties changed", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Value",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("StringValue")
rootInstance.Value = "Hello, world!"
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(PatchSet.isEmpty(patch), "expected empty patch")
end)
it("should ignore unknown properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {
FAKE_PROPERTY = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
--[[
Because rbx_dom_lua resolves non-canonical properties to their canonical
variants, this test does not work as intended.
Instead, heat_xml is diffed with Heat, the canonical property variant,
and a patch trying to assign to heat_xml is generated. This is
incorrect, but will require more invasive changes to fix later.
]]
itFIXME("should ignore unreadable properties", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Fire",
Name = "Fire",
Properties = {
-- heat_xml is a serialization-only property that is not
-- exposed to Lua.
heat_xml = {
Type = "Float32",
Value = 5,
},
},
Children = {},
},
}
local rootInstance = Instance.new("Fire")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
Log.warn("{:#?}", patch)
assert(ok, tostring(patch))
assert(isEmpty(patch.removed))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
end)
it("should generate a patch removing unknown children by default", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local unknownChild = Instance.new("Folder")
unknownChild.Parent = rootInstance
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
expect(#patch.removed).to.equal(1)
expect(patch.removed[1]).to.equal(unknownChild)
end)
it("should generate an empty patch if unknown children should be ignored", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {},
Metadata = {
ignoreUnknownInstances = true,
},
},
}
local rootInstance = Instance.new("Folder")
local unknownChild = Instance.new("Folder")
unknownChild.Parent = rootInstance
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.added))
assert(isEmpty(patch.updated))
assert(isEmpty(patch.removed))
end)
it("should generate a patch with an added child", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Folder",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
knownInstances:insert("ROOT", rootInstance)
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
assert(ok, tostring(patch))
assert(isEmpty(patch.updated))
assert(isEmpty(patch.removed))
expect(size(patch.added)).to.equal(1)
expect(patch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
end)
end

View File

@@ -1,9 +1,11 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
--[[
Attempts to read a property from the given instance.
]]
local function getCanonincalProperty(instance, propertyName)
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
local Error = require(script.Parent.Error)
local function getProperty(instance, propertyName)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
@@ -11,11 +13,17 @@ local function getCanonincalProperty(instance, propertyName)
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
return false, "unknown property"
return false, Error.new(Error.UnknownProperty, {
className = instance.ClassName,
propertyName = propertyName,
})
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
return false, "unreadable property"
return false, Error.new(Error.UnreadableProperty, {
className = instance.ClassName,
propertyName = propertyName,
})
end
local success, valueOrErr = descriptor:read(instance)
@@ -26,14 +34,19 @@ local function getCanonincalProperty(instance, propertyName)
-- If we don't have permission to read a property, we can chalk that up
-- to our database being out of date and the engine being right.
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, "permission error"
return false, Error.new(Error.LackingPropertyPermissions, {
className = instance.ClassName,
propertyName = propertyName,
})
end
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
error(message, 2)
return false, Error.new(Error.OtherPropertyError, {
className = instance.ClassName,
propertyName = propertyName,
})
end
return true, valueOrErr
end
return getCanonincalProperty
return getProperty

View File

@@ -0,0 +1,50 @@
--[[
Defines the process of "hydration" -- matching up a virtual DOM with
concrete instances and assigning them IDs.
]]
local invariant = require(script.Parent.Parent.invariant)
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
local virtualInstance = virtualInstances[rootId]
if virtualInstance == nil then
invariant("Cannot hydrate an instance not present in virtualInstances\nID: {}", rootId)
end
instanceMap:insert(rootId, rootInstance)
local existingChildren = rootInstance:GetChildren()
-- For each existing child, we'll track whether it's been paired with an
-- instance that the Rojo server knows about.
local isExistingChildVisited = {}
for i = 1, #existingChildren do
isExistingChildVisited[i] = false
end
for _, childId in ipairs(virtualInstance.Children) do
local virtualChild = virtualInstances[childId]
for childIndex, childInstance in ipairs(existingChildren) do
if not isExistingChildVisited[childIndex] then
-- We guard accessing Name and ClassName in order to avoid
-- tripping over children of DataModel that Rojo won't have
-- permissions to access at all.
local ok, name, className = pcall(function()
return childInstance.Name, childInstance.ClassName
end)
-- This rule is very conservative and could be loosened in the
-- future, or more heuristics could be introduced.
if ok and name == virtualChild.Name and className == virtualChild.ClassName then
isExistingChildVisited[childIndex] = true
hydrate(instanceMap, virtualInstances, childId, childInstance)
break
end
end
end
end
end
return hydrate

View File

@@ -0,0 +1,129 @@
return function()
local hydrate = require(script.Parent.hydrate)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
it("should match the root instance no matter what", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Model",
Name = "Foo",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should not match children with mismatched ClassName", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
-- ClassName of this instance is intentionally different
local child = Instance.new("Model")
child.Name = "Child"
child.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should not match children with mismatched Name", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
-- Name of this instance is intentionally different
local child = Instance.new("Folder")
child.Name = "Not Child"
child.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(1)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
end)
it("should pair instances with matching Name and ClassName", function()
local knownInstances = InstanceMap.new()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD1", "CHILD2"},
},
CHILD1 = {
ClassName = "Folder",
Name = "Child 1",
Properties = {},
Children = {},
},
CHILD2 = {
ClassName = "Model",
Name = "Child 2",
Properties = {},
Children = {},
},
}
local rootInstance = Instance.new("Folder")
local child1 = Instance.new("Folder")
child1.Name = "Child 1"
child1.Parent = rootInstance
local child2 = Instance.new("Model")
child2.Name = "Child 2"
child2.Parent = rootInstance
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
expect(knownInstances:size()).to.equal(3)
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
end)
end

View File

@@ -0,0 +1,34 @@
--[[
This module defines the meat of the Rojo plugin and how it manages tracking
and mutating the Roblox DOM.
]]
local applyPatch = require(script.applyPatch)
local hydrate = require(script.hydrate)
local diff = require(script.diff)
local Reconciler = {}
Reconciler.__index = Reconciler
function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
}
return setmetatable(self, Reconciler)
end
function Reconciler:applyPatch(patch)
return applyPatch(self.__instanceMap, patch)
end
function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
return hydrate(self.__instanceMap, virtualInstances, rootId, rootInstance)
end
function Reconciler:diff(virtualInstances, rootId)
return diff(self.__instanceMap, virtualInstances, rootId)
end
return Reconciler

View File

@@ -0,0 +1,152 @@
--[[
"Reifies" a virtual DOM, constructing a real DOM with the same shape.
]]
local invariant = require(script.Parent.Parent.invariant)
local PatchSet = require(script.Parent.Parent.PatchSet)
local setProperty = require(script.Parent.setProperty)
local decodeValue = require(script.Parent.decodeValue)
local reifyInner, applyDeferredRefs
local function reify(instanceMap, virtualInstances, rootId, parentInstance)
-- Create an empty patch that will be populated with any parts of this reify
-- that could not happen, like instances that couldn't be created and
-- properties that could not be assigned.
local unappliedPatch = PatchSet.newEmpty()
-- Contains a list of all of the ref properties that we'll need to assign
-- after all instances are created. We apply refs in a second pass, after
-- we create as many instances as we can, so that we ensure that referents
-- can be mapped to instances correctly.
local deferredRefs = {}
reifyInner(instanceMap, virtualInstances, rootId, parentInstance, unappliedPatch, deferredRefs)
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch
end
--[[
Add the given ID and all of its descendants in virtualInstances to the given
PatchSet, marked for addition.
]]
local function addAllToPatch(patchSet, virtualInstances, id)
local virtualInstance = virtualInstances[id]
patchSet.added[id] = virtualInstance
for _, childId in ipairs(virtualInstance.Children) do
addAllToPatch(patchSet, virtualInstances, childId)
end
end
--[[
Inner function that defines the core routine.
]]
function reifyInner(instanceMap, virtualInstances, id, parentInstance, unappliedPatch, deferredRefs)
local virtualInstance = virtualInstances[id]
if virtualInstance == nil then
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
end
-- Instance.new can fail if we're passing in something that can't be
-- created, like a service, something enabled with a feature flag, or
-- something that requires higher security than we have.
local ok, instance = pcall(Instance.new, virtualInstance.ClassName)
if not ok then
addAllToPatch(unappliedPatch, virtualInstances, id)
return
end
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
-- the reason why was uncertain.
instance.Name = virtualInstance.Name
-- Track all of the properties that we've failed to assign to this instance.
local unappliedProperties = {}
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
-- Because refs may refer to instances that we haven't constructed yet,
-- we defer applying any ref properties until all instances are created.
if virtualValue.Type == "Ref" then
table.insert(deferredRefs, {
id = id,
instance = instance,
propertyName = propertyName,
virtualValue = virtualValue,
})
continue
end
local ok, value = decodeValue(virtualValue, instanceMap)
if not ok then
unappliedProperties[propertyName] = virtualValue
continue
end
local ok = setProperty(instance, propertyName, value)
if not ok then
unappliedProperties[propertyName] = virtualValue
end
end
-- If there were any properties that we failed to assign, push this into our
-- unapplied patch as an update that would need to be applied.
if next(unappliedProperties) ~= nil then
table.insert(unappliedPatch.updated, {
id = id,
changedProperties = unappliedProperties,
})
end
for _, childId in ipairs(virtualInstance.Children) do
reifyInner(instanceMap, virtualInstances, childId, instance, unappliedPatch, deferredRefs)
end
instance.Parent = parentInstance
instanceMap:insert(id, instance)
end
function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
local function markFailed(id, propertyName, virtualValue)
-- If there is already an updated entry in the unapplied patch for this
-- ref, use the existing one. This could match other parts of the
-- instance that failed to be created, or even just other refs that
-- failed to apply.
--
-- This is important for instances like selectable GUI objects, which
-- have many similar referent properties.
for _, existingUpdate in ipairs(unappliedPatch.updated) do
if existingUpdate.id == id then
existingUpdate.changedProperties[propertyName] = virtualValue
return
end
end
-- We didn't find an existing entry that matched, so push a new entry
-- into our unapplied patch.
table.insert(unappliedPatch.updated, {
id = id,
changedProperties = {
[propertyName] = virtualValue,
},
})
end
for _, entry in ipairs(deferredRefs) do
local targetInstance = instanceMap.fromIds[entry.virtualValue.Value]
if targetInstance == nil then
markFailed(entry.id, entry.propertyName, entry.virtualValue)
continue
end
local ok = setProperty(entry.instance, entry.propertyName, targetInstance)
if not ok then
markFailed(entry.id, entry.propertyName, entry.virtualValue)
end
end
end
return reify

View File

@@ -0,0 +1,352 @@
return function()
local reify = require(script.Parent.reify)
local PatchSet = require(script.Parent.Parent.PatchSet)
local InstanceMap = require(script.Parent.Parent.InstanceMap)
local Error = require(script.Parent.Error)
local function isEmpty(table)
return next(table) == nil, "Table was not empty"
end
local function size(dict)
local len = 0
for _ in pairs(dict) do
len = len + 1
end
return len
end
it("should throw when given a bogus ID", function()
expect(function()
reify(InstanceMap.new(), {}, "Hi, mom!", game)
end).to.throw()
end)
it("should return an error when given bogus class names", function()
local virtualInstances = {
ROOT = {
ClassName = "Balogna",
Name = "Food",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT", nil)
assert(instanceMap:size() == 0, "expected instanceMap to be empty")
expect(size(unappliedPatch.added)).to.equal(1)
expect(unappliedPatch.added["ROOT"]).to.equal(virtualInstances["ROOT"])
assert(isEmpty(unappliedPatch.removed), "expected no removes")
assert(isEmpty(unappliedPatch.updated), "expected no updates")
end)
it("should assign name and properties", function()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Spaghetti",
Properties = {
Value = {
Type = "String",
Value = "Hello, world!",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("StringValue")
expect(instance.Name).to.equal("Spaghetti")
expect(instance.Value).to.equal("Hello, world!")
expect(instanceMap:size()).to.equal(1)
end)
it("should construct children", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Parent",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("Folder")
expect(instance.Name).to.equal("Parent")
local child = instance.Child
expect(child.ClassName).to.equal("Folder")
expect(instanceMap:size()).to.equal(2)
end)
it("should still construct parents if children fail", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Parent",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "this ain't an Instance",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
expect(size(unappliedPatch.added)).to.equal(1)
expect(unappliedPatch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
assert(isEmpty(unappliedPatch.updated), "expected no updates")
assert(isEmpty(unappliedPatch.removed), "expected no removes")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("Folder")
expect(instance.Name).to.equal("Parent")
expect(#instance:GetChildren()).to.equal(0)
expect(instanceMap:size()).to.equal(1)
end)
it("should fail gracefully when setting erroneous properties", function()
local virtualInstances = {
ROOT = {
ClassName = "StringValue",
Name = "Root",
Properties = {
Value = {
Type = "Vector3",
Value = {1, 2, 3},
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
local instance = instanceMap.fromIds["ROOT"]
expect(instance.ClassName).to.equal("StringValue")
expect(instance.Name).to.equal("Root")
assert(isEmpty(unappliedPatch.added), "expected no additions")
expect(#unappliedPatch.updated).to.equal(1)
assert(isEmpty(unappliedPatch.removed), "expected no removes")
local update = unappliedPatch.updated[1]
expect(update.id).to.equal("ROOT")
expect(size(update.changedProperties)).to.equal(1)
local property = update.changedProperties["Value"]
expect(property).to.equal(virtualInstances["ROOT"].Properties.Value)
end)
-- This is the simplest ref case: ensure that setting a ref property that
-- points to an instance that was previously created as part of the same
-- reify operation works.
it("should apply properties containing refs to ancestors", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD"},
},
CHILD = {
ClassName = "ObjectValue",
Name = "Child",
Properties = {
Value = {
Type = "Ref",
Value = "ROOT",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
local child = instanceMap.fromIds["CHILD"]
expect(child.Value).to.equal(root)
end)
-- This is another simple case: apply a ref property that points to an
-- existing instance. In this test, that instance was created before the
-- reify operation started and is present in instanceMap.
it("should apply properties containing refs to previously-existing instances", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "EXISTING",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local existing = Instance.new("Folder")
existing.Name = "Existing"
instanceMap:insert("EXISTING", existing)
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
expect(root.Value).to.equal(existing)
end)
-- This is a tricky ref case: CHILD_A points to CHILD_B, but is constructed
-- first. Deferred ref application is required to implement this case
-- correctly.
it("should apply properties containing refs to later siblings correctly", function()
local virtualInstances = {
ROOT = {
ClassName = "Folder",
Name = "Root",
Properties = {},
Children = {"CHILD_A", "CHILD_B"},
},
CHILD_A = {
ClassName = "ObjectValue",
Name = "Child A",
Properties = {
Value = {
Type = "Ref",
Value = "Child B",
},
},
Children = {},
},
CHILD_B = {
ClassName = "Folder",
Name = "Child B",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local childA = instanceMap.fromIds["CHILD_A"]
local childB = instanceMap.fromIds["CHILD_B"]
expect(childA.Value).to.equal(childB)
end)
-- This is the classic case that calls for deferred ref application. In this
-- test, the root instance has a ref property that refers to its child. The
-- root is definitely constructed first.
--
-- This is distinct from the sibling case in that the child will be
-- constructed as part of a recursive call before the parent has totally
-- finished. Given deferred refs, this should not fail, but it is a good
-- case to test.
it("should apply properties containing refs to later siblings correctly", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "CHILD",
},
},
Children = {"CHILD"},
},
CHILD = {
ClassName = "Folder",
Name = "Child",
Properties = {},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
local root = instanceMap.fromIds["ROOT"]
local child = instanceMap.fromIds["CHILD"]
expect(root.Value).to.equal(child)
end)
it("should return a partial patch when applying invalid refs", function()
local virtualInstances = {
ROOT = {
ClassName = "ObjectValue",
Name = "Root",
Properties = {
Value = {
Type = "Ref",
Value = "SORRY",
},
},
Children = {},
},
}
local instanceMap = InstanceMap.new()
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
assert(not PatchSet.hasRemoves(unappliedPatch), "expected no removes")
assert(not PatchSet.hasAdditions(unappliedPatch), "expected no additions")
expect(#unappliedPatch.updated).to.equal(1)
local update = unappliedPatch.updated[1]
expect(update.id).to.equal("ROOT")
expect(update.changedProperties.Value).to.equal(virtualInstances["ROOT"].Properties.Value)
end)
end

View File

@@ -0,0 +1,48 @@
--[[
Attempts to set a property on the given instance.
]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
local Log = require(script.Parent.Parent.Parent.Log)
local Error = require(script.Parent.Error)
local function setProperty(instance, propertyName, value)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
Log.trace("Skipping unknown property {}.{}", instance.ClassName, propertyName)
return true
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
return false, Error.new(Error.UnwritableProperty, {
className = instance.ClassName,
propertyName = propertyName,
})
end
local ok, err = descriptor:write(instance, value)
if not ok then
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, Error.new(Error.LackingPropertyPermissions, {
className = instance.ClassName,
propertyName = propertyName,
})
end
return false, Error.new(Error.OtherPropertyError, {
className = instance.ClassName,
propertyName = propertyName,
})
end
return true
end
return setProperty

View File

@@ -5,6 +5,7 @@ local Fmt = require(script.Parent.Parent.Fmt)
local t = require(script.Parent.Parent.t)
local InstanceMap = require(script.Parent.InstanceMap)
local PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler)
local strict = require(script.Parent.strict)
@@ -214,22 +215,36 @@ function ServeSession:__initialSync(rootInstanceId)
-- the tree defined in this response.
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
Log.trace("Computing changes that plugin needs to make to catch up to server...")
-- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs")
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like.
local hydratePatch = self.__reconciler:hydrate(
Log.trace("Computing changes that plugin needs to make to catch up to server...")
local success, catchUpPatch = self.__reconciler:diff(
readResponseBody.instances,
rootInstanceId,
game
)
Log.trace("Computed hydration patch: {:#?}", debugPatch(hydratePatch))
if not success then
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
end
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
-- TODO: Prompt user to notify them of this patch, since it's
-- effectively a conflict between the Rojo server and the client.
-- effectively a conflict between the Rojo server and the client. In
-- the future, we'll ask which changes the user wants to keep.
self.__reconciler:applyPatch(hydratePatch)
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
end)
end
@@ -237,7 +252,12 @@ function ServeSession:__mainSyncLoop()
return self.__apiContext:retrieveMessages()
:andThen(function(messages)
for _, message in ipairs(messages) do
self.__reconciler:applyPatch(message)
local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
end
if self.__status ~= Status.Disconnected then

View File

@@ -1,37 +0,0 @@
local RbxDom = require(script.Parent.Parent.RbxDom)
--[[
Attempts to set a property on the given instance.
]]
local function setCanonicalProperty(instance, propertyName, value)
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
-- We can skip unknown properties; they're not likely reflected to Lua.
--
-- A good example of a property like this is `Model.ModelInPrimary`, which
-- is serialized but not reflected to Lua.
if descriptor == nil then
return false, "unknown property"
end
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
return false, "unwritable property"
end
local success, err = descriptor:write(instance, value)
if not success then
-- If we don't have permission to write a property, we just silently
-- ignore it.
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
return false, "permission error"
end
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
error(message, 2)
end
return true
end
return setCanonicalProperty

4
plugin/test Normal file
View File

@@ -0,0 +1,4 @@
#!/bin/sh
rojo build test-place.project.json -o TestPlace.rbxlx
run-in-roblox --script run-tests.server.lua --place TestPlace.rbxlx

View File

@@ -0,0 +1,28 @@
{
"name": "Rojo Test Place",
"tree": {
"$className": "DataModel",
"ReplicatedStorage": {
"Rojo": {
"$path": "default.project.json"
},
"TestEZ": {
"$path": "modules/testez/lib"
}
},
"ServerScriptService": {
"RunTests": {
"$path": "run-tests.server.lua"
}
},
"Players": {
"$properties": {
"CharacterAutoLoads": false
}
}
}
}

View File

@@ -1,6 +0,0 @@
{
"name": "TestEZ",
"tree": {
"$path": "modules/testez/lib"
}
}

View File

@@ -1,29 +0,0 @@
[package]
name = "rojo-test"
version = "0.1.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018"
publish = false
[features]
default = []
unstable_glob_ignore_paths = []
[dependencies]
env_logger = "0.7.1"
insta = { version = "0.13.1", features = ["redactions"] }
log = "0.4.8"
paste = "0.1.5"
rbx_dom_weak = "1.9.0"
reqwest = "0.9.20"
serde = "1.0.99"
serde_json = "1.0.40"
serde_yaml = "0.8.9"
tempfile = "3.1.0"
walkdir = "2.2.9"
rojo-insta-ext = { path = "../rojo-insta-ext" }
# We execute Rojo via std::process::Command, so depend on it so it's built!
rojo = { path = ".." }

View File

@@ -1,8 +0,0 @@
# rojo-test
This project does end-to-end testing of Rojo by executing it and checking what side-effects it has.
rojo-test is meant to be run as a test with:
```bash
cargo test
```

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/build_test.rs
source: tests/tests/build.rs
expression: contents
---
<roblox version="4">

View File

@@ -1,12 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="StringValue" referent="0">
<Properties>
<string name="Name">plain</string>
<string name="Value">This is a bare text file with no project.</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,11 +0,0 @@
---
source: rojo-test/src/build_test.rs
expression: contents
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">plain_gitkeep</string>
</Properties>
</Item>
</roblox>

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -0,0 +1,9 @@
---
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
protocolVersion: 3
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:

View File

@@ -0,0 +1,9 @@
---
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
protocolVersion: 3
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,5 +1,5 @@
---
source: rojo-test/src/serve_test.rs
source: tests/tests/serve.rs
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
---
messageCursor: 1

View File

@@ -1,19 +0,0 @@
---
source: rojo-test/src/serve_test.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children: []
ClassName: StringValue
Id: id-2
Metadata:
ignoreUnknownInstances: false
Name: just_txt
Parent: ~
Properties:
Value:
Type: String
Value: "Hello, world!"
messageCursor: 0
sessionId: id-1

View File

@@ -1,9 +0,0 @@
---
source: rojo-test/src/serve_test.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
protocolVersion: 3
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

View File

@@ -1,9 +0,0 @@
---
source: rojo-test/src/serve_test.rs
expression: redactions.redacted_yaml(info)
---
expectedPlaceIds: ~
protocolVersion: 3
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1

Some files were not shown because too many files have changed in this diff Show More