mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-23 22:25:26 +00:00
Compare commits
35 Commits
v6.0.0-rc.
...
v6.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
name: CI
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
|
||||||
push:
|
push:
|
||||||
branches: ["*"]
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -12,7 +16,7 @@ jobs:
|
|||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
rust_version: [stable, "1.40.0"]
|
rust_version: [stable, "1.43.1"]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- uses: actions/checkout@v1
|
||||||
@@ -32,10 +36,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cargo fmt -- --check
|
cargo fmt -- --check
|
||||||
cargo clippy
|
cargo clippy
|
||||||
if: matrix.rust_version == 'stable'
|
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
|
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,9 +10,15 @@
|
|||||||
/*.rbxl
|
/*.rbxl
|
||||||
/*.rbxlx
|
/*.rbxlx
|
||||||
|
|
||||||
|
# Test places for the Roblox Studio Plugin
|
||||||
|
/plugin/*.rbxlx
|
||||||
|
|
||||||
# Roblox Studio holds 'lock' files on places
|
# Roblox Studio holds 'lock' files on places
|
||||||
*.rbxl.lock
|
*.rbxl.lock
|
||||||
*.rbxlx.lock
|
*.rbxlx.lock
|
||||||
|
|
||||||
# Snapshot files from the 'insta' Rust crate
|
# 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 = {
|
stds.testez = {
|
||||||
read_globals = {
|
read_globals = {
|
||||||
"describe",
|
"describe",
|
||||||
"it", "itFOCUS", "itSKIP",
|
"it", "itFOCUS", "itSKIP", "itFIXME",
|
||||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||||
"expect",
|
"expect",
|
||||||
}
|
}
|
||||||
|
|||||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -2,6 +2,17 @@
|
|||||||
|
|
||||||
## Unreleased Changes
|
## Unreleased Changes
|
||||||
|
|
||||||
|
## [6.0.0 Release Candidate 2](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (November 19, 2020)
|
||||||
|
* Fixed crash when malformed CSV files are put into a project. ([#310](https://github.com/rojo-rbx/rojo/issues/310))
|
||||||
|
* Fixed incorrect string escaping when producing Lua code from JSON files. ([#314](https://github.com/rojo-rbx/rojo/issues/314))
|
||||||
|
* Fixed performance issues introduced in Rojo 6.0.0-rc.1. ([#317](https://github.com/rojo-rbx/rojo/issues/317))
|
||||||
|
* Fixed `rojo plugin install` subcommand failing for everyone except Rojo developers. ([#320](https://github.com/rojo-rbx/rojo/issues/320))
|
||||||
|
* Updated default place template to take advantage of [#210](https://github.com/rojo-rbx/rojo/pull/210).
|
||||||
|
* Enabled glob ignore patterns by default and removed the `unstable_glob_ignore` feature.
|
||||||
|
* `globIgnorePaths` can be set on a project to a list of globs to ignore.
|
||||||
|
* The Rojo plugin now completes as much as it can from a patch without disconnecting. Warnings are shown in the console.
|
||||||
|
* Fixed 6.0.0-rc.1 regression causing instances that changed ClassName to instead... not change ClassName.
|
||||||
|
|
||||||
## [6.0.0 Release Candidate 1](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (March 29, 2020)
|
## [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.
|
This release jumped from 0.6.0 to 6.0.0. Rojo has been in use in production for many users for quite a long times, and so 6.0 is a more accurate reflection of Rojo's version than a pre-1.0 version.
|
||||||
|
|
||||||
@@ -122,7 +133,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
|||||||
|
|
||||||
## [0.5.0 Alpha 9](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.9) (April 4, 2019)
|
## [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.
|
* 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 `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))
|
* 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.
|
* Improved error messages when malformed CSV files are found in a Rojo project.
|
||||||
@@ -319,4 +330,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
|||||||
* More robust syncing with a new reconciler
|
* More robust syncing with a new reconciler
|
||||||
|
|
||||||
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
|
## [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
|
# 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:
|
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 Rust compiler
|
||||||
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
||||||
* Latest stable [Remodel](https://github.com/rojo-rbx/remodel)
|
* [Foreman](https://github.com/Roblox/foreman)
|
||||||
* Latest stable [run-in-roblox](https://github.com/rojo-rbx/run-in-roblox)
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
Documentation impacts way more people than the individual lines of code we write.
|
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]
|
[package]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
version = "6.0.0-rc.1"
|
version = "6.0.0-rc.2"
|
||||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||||
description = "Enables professional-grade development tools for Roblox developers"
|
description = "Enables professional-grade development tools for Roblox developers"
|
||||||
license = "MPL-2.0"
|
license = "MPL-2.0"
|
||||||
@@ -23,15 +23,11 @@ panic = "abort"
|
|||||||
[features]
|
[features]
|
||||||
default = []
|
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.
|
# Enable this feature to live-reload assets from the web UI.
|
||||||
dev_live_assets = []
|
dev_live_assets = []
|
||||||
|
|
||||||
[workspace]
|
[workspace]
|
||||||
members = [
|
members = [
|
||||||
"rojo-test",
|
|
||||||
"rojo-insta-ext",
|
"rojo-insta-ext",
|
||||||
"clibrojo",
|
"clibrojo",
|
||||||
"memofs",
|
"memofs",
|
||||||
@@ -39,7 +35,6 @@ members = [
|
|||||||
|
|
||||||
default-members = [
|
default-members = [
|
||||||
".",
|
".",
|
||||||
"rojo-test",
|
|
||||||
"rojo-insta-ext",
|
"rojo-insta-ext",
|
||||||
"memofs",
|
"memofs",
|
||||||
]
|
]
|
||||||
@@ -97,7 +92,7 @@ uuid = { version = "0.8.1", features = ["v4", "serde"] }
|
|||||||
winreg = "0.6.2"
|
winreg = "0.6.2"
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
memofs = { version = "0.1.0", path = "memofs" }
|
memofs = { version = "0.1.3", path = "memofs" }
|
||||||
|
|
||||||
anyhow = "1.0.27"
|
anyhow = "1.0.27"
|
||||||
bincode = "1.2.1"
|
bincode = "1.2.1"
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
|
|||||||
|
|
||||||
Pull requests are welcome!
|
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
|
## License
|
||||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
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",
|
"$className": "DataModel",
|
||||||
|
|
||||||
"ReplicatedStorage": {
|
"ReplicatedStorage": {
|
||||||
"$className": "ReplicatedStorage",
|
|
||||||
|
|
||||||
"Common": {
|
"Common": {
|
||||||
"$path": "src/shared"
|
"$path": "src/shared"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"ServerScriptService": {
|
"ServerScriptService": {
|
||||||
"$className": "ServerScriptService",
|
|
||||||
|
|
||||||
"Server": {
|
"Server": {
|
||||||
"$path": "src/server"
|
"$path": "src/server"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"StarterPlayer": {
|
"StarterPlayer": {
|
||||||
"$className": "StarterPlayer",
|
|
||||||
|
|
||||||
"StarterPlayerScripts": {
|
"StarterPlayerScripts": {
|
||||||
"$className": "StarterPlayerScripts",
|
|
||||||
|
|
||||||
"Client": {
|
"Client": {
|
||||||
"$path": "src/client"
|
"$path": "src/client"
|
||||||
}
|
}
|
||||||
@@ -32,7 +24,6 @@
|
|||||||
},
|
},
|
||||||
|
|
||||||
"Workspace": {
|
"Workspace": {
|
||||||
"$className": "Workspace",
|
|
||||||
"$properties": {
|
"$properties": {
|
||||||
"FilteringEnabled": true
|
"FilteringEnabled": true
|
||||||
},
|
},
|
||||||
@@ -60,7 +51,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Lighting": {
|
"Lighting": {
|
||||||
"$className": "Lighting",
|
|
||||||
"$properties": {
|
"$properties": {
|
||||||
"Ambient": [
|
"Ambient": [
|
||||||
0,
|
0,
|
||||||
@@ -74,16 +64,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"SoundService": {
|
"SoundService": {
|
||||||
"$className": "SoundService",
|
|
||||||
"$properties": {
|
"$properties": {
|
||||||
"RespectFilteringEnabled": true
|
"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 {
|
let options = BuildCommand {
|
||||||
project: input,
|
project: input,
|
||||||
|
watch: false,
|
||||||
output,
|
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" }
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# memofs Changelog
|
# memofs Changelog
|
||||||
|
|
||||||
## Unreleased Changes
|
## Unreleased Changes
|
||||||
|
* Added `set_watch_enabled` to `Vfs` and `VfsLock` to allow turning off file watching.
|
||||||
|
|
||||||
|
## 0.1.2 (2020-03-29)
|
||||||
|
* `VfsSnapshot` now implements Serde's `Serialize` and `Deserialize` traits.
|
||||||
|
|
||||||
## 0.1.1 (2020-03-18)
|
## 0.1.1 (2020-03-18)
|
||||||
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
|
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "memofs"
|
name = "memofs"
|
||||||
description = "Virtual filesystem with configurable backends."
|
description = "Virtual filesystem with configurable backends."
|
||||||
version = "0.1.2"
|
version = "0.1.3"
|
||||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||||
edition = "2018"
|
edition = "2018"
|
||||||
readme = "README.md"
|
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)
|
* Configurable caching (write-through, write-around, write-back)
|
||||||
|
|
||||||
## License
|
## 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.
|
/// the public interfaces to this type.
|
||||||
struct VfsInner {
|
struct VfsInner {
|
||||||
backend: Box<dyn VfsBackend>,
|
backend: Box<dyn VfsBackend>,
|
||||||
|
watch_enabled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VfsInner {
|
impl VfsInner {
|
||||||
fn read<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<Vec<u8>>> {
|
fn read<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<Vec<u8>>> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let contents = self.backend.read(path)?;
|
let contents = self.backend.read(path)?;
|
||||||
self.backend.watch(path)?;
|
|
||||||
|
if self.watch_enabled {
|
||||||
|
self.backend.watch(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(Arc::new(contents))
|
Ok(Arc::new(contents))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +164,11 @@ impl VfsInner {
|
|||||||
fn read_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<ReadDir> {
|
fn read_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<ReadDir> {
|
||||||
let path = path.as_ref();
|
let path = path.as_ref();
|
||||||
let dir = self.backend.read_dir(path)?;
|
let dir = self.backend.read_dir(path)?;
|
||||||
self.backend.watch(path)?;
|
|
||||||
|
if self.watch_enabled {
|
||||||
|
self.backend.watch(path)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +224,7 @@ impl Vfs {
|
|||||||
pub fn new<B: VfsBackend>(backend: B) -> Self {
|
pub fn new<B: VfsBackend>(backend: B) -> Self {
|
||||||
let lock = VfsInner {
|
let lock = VfsInner {
|
||||||
backend: Box::new(backend),
|
backend: Box::new(backend),
|
||||||
|
watch_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
Self {
|
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
|
/// Read a file from the VFS, or the underlying backend if it isn't
|
||||||
/// resident.
|
/// resident.
|
||||||
///
|
///
|
||||||
@@ -318,6 +338,15 @@ pub struct VfsLock<'a> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl VfsLock<'_> {
|
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
|
/// Read a file from the VFS, or the underlying backend if it isn't
|
||||||
/// resident.
|
/// 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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Log.error(template, ...)
|
||||||
|
error(Fmt.fmt(template, ...))
|
||||||
|
end
|
||||||
|
|
||||||
return Log
|
return Log
|
||||||
@@ -1,2 +1,2 @@
|
|||||||
# rbx\_dom\_lua
|
# rbx_dom_lua
|
||||||
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx\_dom\_weak and friends.
|
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.
|
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")
|
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.
|
-- Pull the current theme from Roblox Studio and update state with it.
|
||||||
function StudioProvider:updateTheme()
|
function StudioProvider:updateTheme()
|
||||||
local studioTheme = Studio.Theme
|
local studioTheme = getStudio().Theme
|
||||||
|
|
||||||
if studioTheme.Name == "Light" then
|
if studioTheme.Name == "Light" then
|
||||||
self:setState({
|
self:setState({
|
||||||
@@ -82,7 +91,7 @@ function StudioProvider:render()
|
|||||||
end
|
end
|
||||||
|
|
||||||
function StudioProvider:didMount()
|
function StudioProvider:didMount()
|
||||||
self.connection = Studio.ThemeChanged:Connect(function()
|
self.connection = getStudio().ThemeChanged:Connect(function()
|
||||||
self:updateTheme()
|
self:updateTheme()
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
|
|||||||
return strict("Config", {
|
return strict("Config", {
|
||||||
isDevBuild = isDevBuild,
|
isDevBuild = isDevBuild,
|
||||||
codename = "Epiphany",
|
codename = "Epiphany",
|
||||||
version = {6, 0, 0, "-rc.1"},
|
version = {6, 0, 0, "-rc.2"},
|
||||||
expectedServerVersionString = "6.0 or newer",
|
expectedServerVersionString = "6.0 or newer",
|
||||||
protocolVersion = 3,
|
protocolVersion = 3,
|
||||||
defaultHost = "localhost",
|
defaultHost = "localhost",
|
||||||
|
|||||||
@@ -33,6 +33,16 @@ function InstanceMap.new(onInstanceChanged)
|
|||||||
return setmetatable(self, InstanceMap)
|
return setmetatable(self, InstanceMap)
|
||||||
end
|
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.
|
Disconnect all connections and release all instance references.
|
||||||
]]
|
]]
|
||||||
@@ -69,6 +79,9 @@ function InstanceMap:__fmtDebug(output)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function InstanceMap:insert(id, instance)
|
function InstanceMap:insert(id, instance)
|
||||||
|
self:removeId(id)
|
||||||
|
self:removeInstance(instance)
|
||||||
|
|
||||||
self.fromIds[id] = instance
|
self.fromIds[id] = instance
|
||||||
self.fromInstances[instance] = id
|
self.fromInstances[instance] = id
|
||||||
self:__connectSignals(instance)
|
self:__connectSignals(instance)
|
||||||
@@ -81,8 +94,6 @@ function InstanceMap:removeId(id)
|
|||||||
self:__disconnectSignals(instance)
|
self:__disconnectSignals(instance)
|
||||||
self.fromIds[id] = nil
|
self.fromIds[id] = nil
|
||||||
self.fromInstances[instance] = nil
|
self.fromInstances[instance] = nil
|
||||||
else
|
|
||||||
Log.warn("Attempted to remove nonexistant ID {}", id)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -93,8 +104,6 @@ function InstanceMap:removeInstance(instance)
|
|||||||
if id ~= nil then
|
if id ~= nil then
|
||||||
self.fromInstances[instance] = nil
|
self.fromInstances[instance] = nil
|
||||||
self.fromIds[id] = nil
|
self.fromIds[id] = nil
|
||||||
else
|
|
||||||
Log.warn("Attempted to remove nonexistant instance {}", instance)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -102,10 +111,14 @@ function InstanceMap:destroyInstance(instance)
|
|||||||
local id = self.fromInstances[instance]
|
local id = self.fromInstances[instance]
|
||||||
|
|
||||||
if id ~= nil then
|
if id ~= nil then
|
||||||
self:destroyId(id)
|
self:removeId(id)
|
||||||
else
|
|
||||||
Log.warn("Attempted to destroy untracked instance {}", instance)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
for _, descendantInstance in ipairs(instance:GetDescendants()) do
|
||||||
|
self:removeInstance(descendantInstance)
|
||||||
|
end
|
||||||
|
|
||||||
|
instance:Destroy()
|
||||||
end
|
end
|
||||||
|
|
||||||
function InstanceMap:destroyId(id)
|
function InstanceMap:destroyId(id)
|
||||||
@@ -113,21 +126,11 @@ function InstanceMap:destroyId(id)
|
|||||||
self:removeId(id)
|
self:removeId(id)
|
||||||
|
|
||||||
if instance ~= nil then
|
if instance ~= nil then
|
||||||
local descendantsToDestroy = {}
|
for _, descendantInstance in ipairs(instance:GetDescendants()) do
|
||||||
|
self:removeInstance(descendantInstance)
|
||||||
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)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
instance:Destroy()
|
instance:Destroy()
|
||||||
else
|
|
||||||
Log.warn("Attempted to destroy nonexistant ID {}", id)
|
|
||||||
end
|
end
|
||||||
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)
|
function PatchSet.newEmpty()
|
||||||
error("not yet implemented", 2)
|
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
|
end
|
||||||
|
|
||||||
return PatchSet
|
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
|
||||||
35
plugin/src/Reconciler/decodeValue.lua
Normal file
35
plugin/src/Reconciler/decodeValue.lua
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
--[[
|
||||||
|
Transforms a value encoded by rbx_dom_weak on the server side into a value
|
||||||
|
usable by Rojo's reconciler, potentially using RbxDom.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
|
||||||
|
local Error = require(script.Parent.Error)
|
||||||
|
|
||||||
|
local function decodeValue(virtualValue, instanceMap)
|
||||||
|
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
|
||||||
|
if virtualValue.Type == "Ref" then
|
||||||
|
local instance = instanceMap.fromIds[virtualValue.Value]
|
||||||
|
|
||||||
|
if instance ~= nil then
|
||||||
|
return true, instance
|
||||||
|
else
|
||||||
|
return false, Error.new(Error.RefDidNotExist, {
|
||||||
|
virtualValue = virtualValue,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, decodedValue = RbxDom.EncodedValue.decode(virtualValue)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
return false, Error.new(Error.CannotDecodeValue, {
|
||||||
|
virtualValue = virtualValue,
|
||||||
|
innerError = decodedValue,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
return true, decodedValue
|
||||||
|
end
|
||||||
|
|
||||||
|
return decodeValue
|
||||||
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.
|
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)
|
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||||
|
|
||||||
-- We can skip unknown properties; they're not likely reflected to Lua.
|
-- 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
|
-- A good example of a property like this is `Model.ModelInPrimary`, which
|
||||||
-- is serialized but not reflected to Lua.
|
-- is serialized but not reflected to Lua.
|
||||||
if descriptor == nil then
|
if descriptor == nil then
|
||||||
return false, "unknown property"
|
return false, Error.new(Error.UnknownProperty, {
|
||||||
|
className = instance.ClassName,
|
||||||
|
propertyName = propertyName,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
|
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
|
end
|
||||||
|
|
||||||
local success, valueOrErr = descriptor:read(instance)
|
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
|
-- 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.
|
-- 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
|
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
|
end
|
||||||
|
|
||||||
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
|
return false, Error.new(Error.OtherPropertyError, {
|
||||||
error(message, 2)
|
className = instance.ClassName,
|
||||||
|
propertyName = propertyName,
|
||||||
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
return true, valueOrErr
|
return true, valueOrErr
|
||||||
end
|
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
|
||||||
152
plugin/src/Reconciler/reify.lua
Normal file
152
plugin/src/Reconciler/reify.lua
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
--[[
|
||||||
|
"Reifies" a virtual DOM, constructing a real DOM with the same shape.
|
||||||
|
]]
|
||||||
|
|
||||||
|
local invariant = require(script.Parent.Parent.invariant)
|
||||||
|
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||||
|
local setProperty = require(script.Parent.setProperty)
|
||||||
|
local decodeValue = require(script.Parent.decodeValue)
|
||||||
|
|
||||||
|
local reifyInner, applyDeferredRefs
|
||||||
|
|
||||||
|
local function reify(instanceMap, virtualInstances, rootId, parentInstance)
|
||||||
|
-- Create an empty patch that will be populated with any parts of this reify
|
||||||
|
-- that could not happen, like instances that couldn't be created and
|
||||||
|
-- properties that could not be assigned.
|
||||||
|
local unappliedPatch = PatchSet.newEmpty()
|
||||||
|
|
||||||
|
-- Contains a list of all of the ref properties that we'll need to assign
|
||||||
|
-- after all instances are created. We apply refs in a second pass, after
|
||||||
|
-- we create as many instances as we can, so that we ensure that referents
|
||||||
|
-- can be mapped to instances correctly.
|
||||||
|
local deferredRefs = {}
|
||||||
|
|
||||||
|
reifyInner(instanceMap, virtualInstances, rootId, parentInstance, unappliedPatch, deferredRefs)
|
||||||
|
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
|
||||||
|
|
||||||
|
return unappliedPatch
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Add the given ID and all of its descendants in virtualInstances to the given
|
||||||
|
PatchSet, marked for addition.
|
||||||
|
]]
|
||||||
|
local function addAllToPatch(patchSet, virtualInstances, id)
|
||||||
|
local virtualInstance = virtualInstances[id]
|
||||||
|
patchSet.added[id] = virtualInstance
|
||||||
|
|
||||||
|
for _, childId in ipairs(virtualInstance.Children) do
|
||||||
|
addAllToPatch(patchSet, virtualInstances, childId)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
--[[
|
||||||
|
Inner function that defines the core routine.
|
||||||
|
]]
|
||||||
|
function reifyInner(instanceMap, virtualInstances, id, parentInstance, unappliedPatch, deferredRefs)
|
||||||
|
local virtualInstance = virtualInstances[id]
|
||||||
|
|
||||||
|
if virtualInstance == nil then
|
||||||
|
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Instance.new can fail if we're passing in something that can't be
|
||||||
|
-- created, like a service, something enabled with a feature flag, or
|
||||||
|
-- something that requires higher security than we have.
|
||||||
|
local ok, instance = pcall(Instance.new, virtualInstance.ClassName)
|
||||||
|
|
||||||
|
if not ok then
|
||||||
|
addAllToPatch(unappliedPatch, virtualInstances, id)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
|
||||||
|
-- the reason why was uncertain.
|
||||||
|
instance.Name = virtualInstance.Name
|
||||||
|
|
||||||
|
-- Track all of the properties that we've failed to assign to this instance.
|
||||||
|
local unappliedProperties = {}
|
||||||
|
|
||||||
|
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
|
||||||
|
-- Because refs may refer to instances that we haven't constructed yet,
|
||||||
|
-- we defer applying any ref properties until all instances are created.
|
||||||
|
if virtualValue.Type == "Ref" then
|
||||||
|
table.insert(deferredRefs, {
|
||||||
|
id = id,
|
||||||
|
instance = instance,
|
||||||
|
propertyName = propertyName,
|
||||||
|
virtualValue = virtualValue,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok, value = decodeValue(virtualValue, instanceMap)
|
||||||
|
if not ok then
|
||||||
|
unappliedProperties[propertyName] = virtualValue
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok = setProperty(instance, propertyName, value)
|
||||||
|
if not ok then
|
||||||
|
unappliedProperties[propertyName] = virtualValue
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If there were any properties that we failed to assign, push this into our
|
||||||
|
-- unapplied patch as an update that would need to be applied.
|
||||||
|
if next(unappliedProperties) ~= nil then
|
||||||
|
table.insert(unappliedPatch.updated, {
|
||||||
|
id = id,
|
||||||
|
changedProperties = unappliedProperties,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, childId in ipairs(virtualInstance.Children) do
|
||||||
|
reifyInner(instanceMap, virtualInstances, childId, instance, unappliedPatch, deferredRefs)
|
||||||
|
end
|
||||||
|
|
||||||
|
instance.Parent = parentInstance
|
||||||
|
instanceMap:insert(id, instance)
|
||||||
|
end
|
||||||
|
|
||||||
|
function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
|
||||||
|
local function markFailed(id, propertyName, virtualValue)
|
||||||
|
-- If there is already an updated entry in the unapplied patch for this
|
||||||
|
-- ref, use the existing one. This could match other parts of the
|
||||||
|
-- instance that failed to be created, or even just other refs that
|
||||||
|
-- failed to apply.
|
||||||
|
--
|
||||||
|
-- This is important for instances like selectable GUI objects, which
|
||||||
|
-- have many similar referent properties.
|
||||||
|
for _, existingUpdate in ipairs(unappliedPatch.updated) do
|
||||||
|
if existingUpdate.id == id then
|
||||||
|
existingUpdate.changedProperties[propertyName] = virtualValue
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- We didn't find an existing entry that matched, so push a new entry
|
||||||
|
-- into our unapplied patch.
|
||||||
|
table.insert(unappliedPatch.updated, {
|
||||||
|
id = id,
|
||||||
|
changedProperties = {
|
||||||
|
[propertyName] = virtualValue,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, entry in ipairs(deferredRefs) do
|
||||||
|
local targetInstance = instanceMap.fromIds[entry.virtualValue.Value]
|
||||||
|
if targetInstance == nil then
|
||||||
|
markFailed(entry.id, entry.propertyName, entry.virtualValue)
|
||||||
|
continue
|
||||||
|
end
|
||||||
|
|
||||||
|
local ok = setProperty(entry.instance, entry.propertyName, targetInstance)
|
||||||
|
if not ok then
|
||||||
|
markFailed(entry.id, entry.propertyName, entry.virtualValue)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return reify
|
||||||
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 t = require(script.Parent.Parent.t)
|
||||||
|
|
||||||
local InstanceMap = require(script.Parent.InstanceMap)
|
local InstanceMap = require(script.Parent.InstanceMap)
|
||||||
|
local PatchSet = require(script.Parent.PatchSet)
|
||||||
local Reconciler = require(script.Parent.Reconciler)
|
local Reconciler = require(script.Parent.Reconciler)
|
||||||
local strict = require(script.Parent.strict)
|
local strict = require(script.Parent.strict)
|
||||||
|
|
||||||
@@ -214,22 +215,36 @@ function ServeSession:__initialSync(rootInstanceId)
|
|||||||
-- the tree defined in this response.
|
-- the tree defined in this response.
|
||||||
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
|
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
|
-- Calculate the initial patch to apply to the DataModel to catch us
|
||||||
-- up to what Rojo thinks the place should look like.
|
-- 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,
|
readResponseBody.instances,
|
||||||
rootInstanceId,
|
rootInstanceId,
|
||||||
game
|
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
|
-- 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)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -237,7 +252,12 @@ function ServeSession:__mainSyncLoop()
|
|||||||
return self.__apiContext:retrieveMessages()
|
return self.__apiContext:retrieveMessages()
|
||||||
:andThen(function(messages)
|
:andThen(function(messages)
|
||||||
for _, message in ipairs(messages) do
|
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
|
end
|
||||||
|
|
||||||
if self.__status ~= Status.Disconnected then
|
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
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<roblox version="4">
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/build_test.rs
|
source: tests/tests/build.rs
|
||||||
expression: contents
|
expression: contents
|
||||||
---
|
---
|
||||||
<roblox version="4">
|
<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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
instances:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/serve_test.rs
|
source: tests/tests/serve.rs
|
||||||
expression: redactions.redacted_yaml(info)
|
expression: redactions.redacted_yaml(info)
|
||||||
---
|
---
|
||||||
expectedPlaceIds: ~
|
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, ())"
|
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
|
||||||
---
|
---
|
||||||
messageCursor: 1
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
instances:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/serve_test.rs
|
source: tests/tests/serve.rs
|
||||||
expression: redactions.redacted_yaml(info)
|
expression: redactions.redacted_yaml(info)
|
||||||
---
|
---
|
||||||
expectedPlaceIds: ~
|
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, ())"
|
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
|
||||||
---
|
---
|
||||||
messageCursor: 1
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
instances:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/serve_test.rs
|
source: tests/tests/serve.rs
|
||||||
expression: redactions.redacted_yaml(info)
|
expression: redactions.redacted_yaml(info)
|
||||||
---
|
---
|
||||||
expectedPlaceIds: ~
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
instances:
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
source: rojo-test/src/serve_test.rs
|
source: tests/tests/serve.rs
|
||||||
expression: redactions.redacted_yaml(info)
|
expression: redactions.redacted_yaml(info)
|
||||||
---
|
---
|
||||||
expectedPlaceIds: ~
|
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, ())"
|
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
|
||||||
---
|
---
|
||||||
messageCursor: 1
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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, ())"
|
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
|
||||||
---
|
---
|
||||||
messageCursor: 1
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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)"
|
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
|
||||||
---
|
---
|
||||||
instances:
|
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, ())"
|
expression: "subscribe_response.intern_and_redact(&mut redactions, ())"
|
||||||
---
|
---
|
||||||
messageCursor: 1
|
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