forked from rojo-rbx/rojo
Compare commits
43 Commits
v6.0.0-rc.
...
v6.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b8308282 | ||
|
|
a1f7cdc2b6 | ||
|
|
dcb30351c5 | ||
|
|
1c0dc60071 | ||
|
|
0401c3ac0e | ||
|
|
3b800d1cd7 | ||
|
|
35efb464e5 | ||
|
|
19a955a327 | ||
|
|
836b18e68a | ||
|
|
046dc0d598 | ||
|
|
039d92ce78 | ||
|
|
2136da15d6 | ||
|
|
e5041d80ef | ||
|
|
f66860bdfe | ||
|
|
50f0a2bd2e | ||
|
|
7cd9bd383e | ||
|
|
45a20a1633 | ||
|
|
ec5b3f80ef | ||
|
|
3b257ea87a | ||
|
|
6b82cead9c | ||
|
|
79ae4c52cd | ||
|
|
a4616cda7d | ||
|
|
95648361be | ||
|
|
0c41e9c10b | ||
|
|
61c7ef3cb0 | ||
|
|
65898125d0 | ||
|
|
da05078ff3 | ||
|
|
badb5c3636 | ||
|
|
9453588ab1 | ||
|
|
4cbb3874a4 | ||
|
|
940aff7ef4 | ||
|
|
a3edb93273 | ||
|
|
782b054b1a | ||
|
|
fc27b2911e | ||
|
|
486b067567 | ||
|
|
bdd1afea57 | ||
|
|
5ccd02939b | ||
|
|
ca5b8ab309 | ||
|
|
9481fdd38d | ||
|
|
56bf6d282b | ||
|
|
5364c9c1bc | ||
|
|
a4d4beeb97 | ||
|
|
30a01381be |
18
.github/workflows/ci.yml
vendored
18
.github/workflows/ci.yml
vendored
@@ -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
8
.gitignore
vendored
@@ -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
|
||||
@@ -33,7 +33,7 @@ stds.plugin = {
|
||||
stds.testez = {
|
||||
read_globals = {
|
||||
"describe",
|
||||
"it", "itFOCUS", "itSKIP",
|
||||
"it", "itFOCUS", "itSKIP", "itFIXME",
|
||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||
"expect",
|
||||
}
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -2,6 +2,21 @@
|
||||
|
||||
## Unreleased Changes
|
||||
|
||||
## [6.0.0 Release Candidate 3](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.3) (November 19, 2020)
|
||||
* Fixed the Rojo plugin attempted to write the non-scriptable properties `Instance.SourceAssetId` and `HttpServer.HttpEnabled`.
|
||||
* Fixed the Rojo plugin's handling of null referents.
|
||||
|
||||
## [6.0.0 Release Candidate 2](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.2) (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 +137,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 +334,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)
|
||||
@@ -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
994
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "6.0.0-rc.1"
|
||||
version = "6.0.0-rc.3"
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
3
foreman.toml
Normal 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" }
|
||||
@@ -2,6 +2,12 @@
|
||||
|
||||
## Unreleased Changes
|
||||
|
||||
## 0.1.3 (2020-11-19)
|
||||
* 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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
@@ -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
10
perf-test.sh
Normal 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
|
||||
@@ -53,4 +53,8 @@ function Log.warn(template, ...)
|
||||
end
|
||||
end
|
||||
|
||||
function Log.error(template, ...)
|
||||
error(Fmt.fmt(template, ...))
|
||||
end
|
||||
|
||||
return Log
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.3"},
|
||||
expectedServerVersionString = "6.0 or newer",
|
||||
protocolVersion = 3,
|
||||
defaultHost = "localhost",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
37
plugin/src/Reconciler/Error.lua
Normal file
37
plugin/src/Reconciler/Error.lua
Normal 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
|
||||
200
plugin/src/Reconciler/applyPatch.lua
Normal file
200
plugin/src/Reconciler/applyPatch.lua
Normal 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
|
||||
198
plugin/src/Reconciler/applyPatch.spec.lua
Normal file
198
plugin/src/Reconciler/applyPatch.spec.lua
Normal 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
|
||||
39
plugin/src/Reconciler/decodeValue.lua
Normal file
39
plugin/src/Reconciler/decodeValue.lua
Normal file
@@ -0,0 +1,39 @@
|
||||
--[[
|
||||
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
|
||||
if virtualValue.Value == nil then
|
||||
return true, nil
|
||||
end
|
||||
|
||||
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
|
||||
148
plugin/src/Reconciler/diff.lua
Normal file
148
plugin/src/Reconciler/diff.lua
Normal 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
|
||||
292
plugin/src/Reconciler/diff.spec.lua
Normal file
292
plugin/src/Reconciler/diff.spec.lua
Normal 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
|
||||
@@ -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
|
||||
50
plugin/src/Reconciler/hydrate.lua
Normal file
50
plugin/src/Reconciler/hydrate.lua
Normal 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
|
||||
129
plugin/src/Reconciler/hydrate.spec.lua
Normal file
129
plugin/src/Reconciler/hydrate.spec.lua
Normal 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
|
||||
34
plugin/src/Reconciler/init.lua
Normal file
34
plugin/src/Reconciler/init.lua
Normal 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
|
||||
158
plugin/src/Reconciler/reify.lua
Normal file
158
plugin/src/Reconciler/reify.lua
Normal file
@@ -0,0 +1,158 @@
|
||||
--[[
|
||||
"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 virtualValue = entry.virtualValue
|
||||
|
||||
if virtualValue.Value == nil then
|
||||
continue
|
||||
end
|
||||
|
||||
local targetInstance = instanceMap.fromIds[virtualValue.Value]
|
||||
if targetInstance == nil then
|
||||
markFailed(entry.id, entry.propertyName, virtualValue)
|
||||
continue
|
||||
end
|
||||
|
||||
local ok = setProperty(entry.instance, entry.propertyName, targetInstance)
|
||||
if not ok then
|
||||
markFailed(entry.id, entry.propertyName, virtualValue)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return reify
|
||||
352
plugin/src/Reconciler/reify.spec.lua
Normal file
352
plugin/src/Reconciler/reify.spec.lua
Normal 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
|
||||
48
plugin/src/Reconciler/setProperty.lua
Normal file
48
plugin/src/Reconciler/setProperty.lua
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
4
plugin/test
Normal 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
|
||||
28
plugin/test-place.project.json
Normal file
28
plugin/test-place.project.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "TestEZ",
|
||||
"tree": {
|
||||
"$path": "modules/testez/lib"
|
||||
}
|
||||
}
|
||||
@@ -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 = ".." }
|
||||
@@ -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
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/serve_test.rs
|
||||
source: tests/tests/serve.rs
|
||||
expression: redactions.redacted_yaml(info)
|
||||
---
|
||||
expectedPlaceIds: ~
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/serve_test.rs
|
||||
source: tests/tests/serve.rs
|
||||
expression: redactions.redacted_yaml(info)
|
||||
---
|
||||
expectedPlaceIds: ~
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/serve_test.rs
|
||||
source: tests/tests/serve.rs
|
||||
expression: redactions.redacted_yaml(info)
|
||||
---
|
||||
expectedPlaceIds: ~
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/serve_test.rs
|
||||
source: tests/tests/serve.rs
|
||||
expression: redactions.redacted_yaml(info)
|
||||
---
|
||||
expectedPlaceIds: ~
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user