mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-10-23 05:51:39 +00:00
Update dependencies and enhance VFS staleness testing
- Updated several dependencies in Cargo.lock, including `clap`, `env_logger`, and `rand`, to their latest versions for improved functionality and security. - Introduced a new API endpoint `/api/validate-tree` for checking tree freshness without applying corrections, enhancing the validation process. - Added a `TreeFreshnessReport` struct to encapsulate the results of the tree freshness check, providing detailed insights into discrepancies. - Completed multiple todos in the VFS staleness test plan, ensuring comprehensive coverage for various scenarios and improving the robustness of the testing framework. These changes enhance the project's dependency management and testing capabilities, ensuring better alignment with the latest implementation standards.
This commit is contained in:
parent
200c5ca219
commit
e2f515d8eb
16 changed files with 538 additions and 39 deletions
|
|
@ -4,28 +4,28 @@ overview: Create test fixtures that replicate VFS tree staleness, add a tree-fre
|
|||
todos:
|
||||
- id: api-endpoint
|
||||
content: Add GET /api/validate-tree endpoint that re-snapshots and returns drift counts without correcting the tree
|
||||
status: pending
|
||||
status: completed
|
||||
- id: session-method
|
||||
content: Extract check_tree_freshness() in ServeSession (no age gate, no apply, returns counts) and set VALIDATE_TREE_ON_CONNECT = false
|
||||
status: pending
|
||||
status: completed
|
||||
- id: test-helper
|
||||
content: Add assert_tree_fresh() helper and ValidateTreeResponse type to tests/rojo_test/serve_util.rs
|
||||
status: pending
|
||||
status: completed
|
||||
- id: fixture
|
||||
content: Create rojo-test/serve-tests/stale_tree/ fixture with project file and multiple scripts
|
||||
status: pending
|
||||
status: completed
|
||||
- id: staleness-tests
|
||||
content: "Write 4 new staleness tests: bulk changes, post-API external edit, rapid delete/recreate, directory restructure"
|
||||
status: pending
|
||||
status: completed
|
||||
- id: append-freshness
|
||||
content: Append assert_tree_fresh() to all watcher_* and echo_suppression_* tests in two_way_sync.rs
|
||||
status: pending
|
||||
status: completed
|
||||
- id: tighten-echo
|
||||
content: Tighten echo suppression cursor_delta assertion from < 10 to <= 3
|
||||
status: pending
|
||||
status: completed
|
||||
- id: chaos-fuzzer
|
||||
content: Write chaos fuzzer test that slams the filesystem with random operations for 10 seconds, then asserts tree freshness
|
||||
status: pending
|
||||
status: completed
|
||||
isProject: false
|
||||
---
|
||||
|
||||
|
|
|
|||
89
Cargo.lock
generated
89
Cargo.lock
generated
|
|
@ -179,13 +179,13 @@ dependencies = [
|
|||
"bincode",
|
||||
"blake3",
|
||||
"bytes",
|
||||
"clap 4.5.57",
|
||||
"clap 4.5.58",
|
||||
"criterion",
|
||||
"crossbeam-channel",
|
||||
"csv",
|
||||
"data-encoding",
|
||||
"embed-resource",
|
||||
"env_logger 0.11.8",
|
||||
"env_logger 0.11.9",
|
||||
"float-cmp",
|
||||
"fs-err",
|
||||
"futures",
|
||||
|
|
@ -208,6 +208,7 @@ dependencies = [
|
|||
"pathdiff",
|
||||
"pretty_assertions",
|
||||
"profiling",
|
||||
"rand 0.10.0",
|
||||
"rayon",
|
||||
"rbx_binary",
|
||||
"rbx_cookie",
|
||||
|
|
@ -273,9 +274,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.0"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
|
|
@ -370,7 +371,7 @@ dependencies = [
|
|||
"cc",
|
||||
"cfg-if 1.0.4",
|
||||
"constant_time_eq 0.4.2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -480,6 +481,17 @@ version = "0.2.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ciborium"
|
||||
version = "0.2.2"
|
||||
|
|
@ -524,9 +536,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.5.57"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a"
|
||||
checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806"
|
||||
dependencies = [
|
||||
"clap_builder",
|
||||
"clap_derive",
|
||||
|
|
@ -534,9 +546,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_builder"
|
||||
version = "4.5.57"
|
||||
version = "4.5.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238"
|
||||
checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -558,9 +570,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "clap_lex"
|
||||
version = "0.7.7"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
|
|
@ -670,6 +682,15 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
|
|
@ -689,7 +710,7 @@ dependencies = [
|
|||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap 4.5.57",
|
||||
"clap 4.5.58",
|
||||
"criterion-plot",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
|
|
@ -929,9 +950,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "0.1.4"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2"
|
||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
|
|
@ -952,9 +973,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.8"
|
||||
version = "0.11.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
dependencies = [
|
||||
"anstream",
|
||||
"anstyle",
|
||||
|
|
@ -1225,6 +1246,7 @@ dependencies = [
|
|||
"cfg-if 1.0.4",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.0",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
|
|
@ -2323,7 +2345,7 @@ dependencies = [
|
|||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
|
|
@ -2380,7 +2402,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
dependencies = [
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2390,7 +2423,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2402,6 +2435,12 @@ dependencies = [
|
|||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba"
|
||||
|
||||
[[package]]
|
||||
name = "rayon"
|
||||
version = "1.11.0"
|
||||
|
|
@ -2428,7 +2467,7 @@ version = "2.0.1"
|
|||
dependencies = [
|
||||
"ahash",
|
||||
"criterion",
|
||||
"env_logger 0.11.8",
|
||||
"env_logger 0.11.9",
|
||||
"heck",
|
||||
"insta",
|
||||
"log",
|
||||
|
|
@ -2500,7 +2539,7 @@ dependencies = [
|
|||
"blake3",
|
||||
"insta",
|
||||
"lazy_static",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
|
|
@ -2512,7 +2551,7 @@ version = "2.0.1"
|
|||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.22.1",
|
||||
"env_logger 0.11.8",
|
||||
"env_logger 0.11.9",
|
||||
"heck",
|
||||
"insta",
|
||||
"log",
|
||||
|
|
@ -3022,7 +3061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -3039,7 +3078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
|
|
@ -3667,7 +3706,7 @@ dependencies = [
|
|||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.9.2",
|
||||
"sha1 0.10.6",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
|
|
|
|||
|
|
@ -133,3 +133,4 @@ regex = "1"
|
|||
serde_yaml = "0.9"
|
||||
tempfile = "3"
|
||||
walkdir = "2.5.0"
|
||||
rand = "0.10.0"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
{
|
||||
"$className": "Folder"
|
||||
"className": "Folder"
|
||||
}
|
||||
|
|
|
|||
10
rojo-test/serve-tests/stale_tree/default.project.json5
Normal file
10
rojo-test/serve-tests/stale_tree/default.project.json5
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"name": "stale_tree",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
rojo-test/serve-tests/stale_tree/src/a.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/a.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "a"
|
||||
1
rojo-test/serve-tests/stale_tree/src/b.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/b.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "b"
|
||||
1
rojo-test/serve-tests/stale_tree/src/c.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/c.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "c"
|
||||
1
rojo-test/serve-tests/stale_tree/src/d.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/d.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "d"
|
||||
1
rojo-test/serve-tests/stale_tree/src/e.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/e.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "e"
|
||||
1
rojo-test/serve-tests/stale_tree/src/sub/nested.luau
Normal file
1
rojo-test/serve-tests/stale_tree/src/sub/nested.luau
Normal file
|
|
@ -0,0 +1 @@
|
|||
return "nested"
|
||||
|
|
@ -29,6 +29,7 @@ mod web;
|
|||
|
||||
pub use project::*;
|
||||
pub use rojo_ref::*;
|
||||
pub use serve_session::TreeFreshnessReport;
|
||||
pub use session_id::SessionId;
|
||||
pub use snapshot::{
|
||||
InstanceContext, InstanceMetadata, InstanceSnapshot, InstanceWithMeta, InstanceWithMetaMut,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ use crate::{
|
|||
/// Set to `true` to validate on plugin connect (useful for testing, do not enable on production).
|
||||
const VALIDATE_TREE_ON_CONNECT: bool = false;
|
||||
|
||||
/// Result of a read-only tree freshness check. Reports how many instances
|
||||
/// differ between the in-memory tree and the real filesystem.
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TreeFreshnessReport {
|
||||
pub is_fresh: bool,
|
||||
pub added: usize,
|
||||
pub removed: usize,
|
||||
pub updated: usize,
|
||||
pub elapsed_ms: f64,
|
||||
}
|
||||
|
||||
/// Contains all of the state for a Rojo serve session. A serve session is used
|
||||
/// when we need to build a Rojo tree and possibly rebuild it when input files
|
||||
/// change.
|
||||
|
|
@ -264,6 +276,45 @@ impl ServeSession {
|
|||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Read-only check: re-snapshots from disk and returns how many
|
||||
/// instances differ between the in-memory tree and the real filesystem.
|
||||
/// Does NOT apply corrections — the tree is left unchanged.
|
||||
pub fn check_tree_freshness(&self) -> TreeFreshnessReport {
|
||||
let start = Instant::now();
|
||||
let start_path = self.root_project.folder_location();
|
||||
let instance_context = InstanceContext::new();
|
||||
|
||||
let snapshot = match snapshot_from_vfs(&instance_context, &self.vfs, start_path) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
log::error!("Tree freshness check snapshot error: {:?}", e);
|
||||
return TreeFreshnessReport {
|
||||
is_fresh: false,
|
||||
added: 0,
|
||||
removed: 0,
|
||||
updated: 0,
|
||||
elapsed_ms: start.elapsed().as_secs_f64() * 1000.0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let tree = self.tree.lock().unwrap();
|
||||
let root_id = tree.get_root_id();
|
||||
let patch_set = compute_patch_set(snapshot, &tree, root_id);
|
||||
|
||||
let added = patch_set.added_instances.len();
|
||||
let removed = patch_set.removed_instances.len();
|
||||
let updated = patch_set.updated_instances.len();
|
||||
|
||||
TreeFreshnessReport {
|
||||
is_fresh: added == 0 && removed == 0 && updated == 0,
|
||||
added,
|
||||
removed,
|
||||
updated,
|
||||
elapsed_ms: start.elapsed().as_secs_f64() * 1000.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-snapshots the project tree from the real filesystem and patches
|
||||
/// the in-memory tree to correct any drift caused by missed VFS watcher
|
||||
/// events. Called on plugin connect to guarantee the tree is fresh.
|
||||
|
|
|
|||
|
|
@ -151,6 +151,7 @@ pub async fn call(
|
|||
service.handle_api_open(request).await
|
||||
}
|
||||
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
|
||||
(&Method::GET, "/api/validate-tree") => service.handle_api_validate_tree().await,
|
||||
|
||||
(_method, path) => msgpack(
|
||||
ErrorResponse::not_found(format!("Route not found: {}", path)),
|
||||
|
|
@ -285,6 +286,13 @@ impl ApiService {
|
|||
})
|
||||
}
|
||||
|
||||
/// Read-only tree freshness check for test infrastructure.
|
||||
/// Re-snapshots from disk and returns drift counts without applying corrections.
|
||||
async fn handle_api_validate_tree(&self) -> Response<Full<Bytes>> {
|
||||
let report = self.serve_session.check_tree_freshness();
|
||||
msgpack_ok(&report)
|
||||
}
|
||||
|
||||
/// Handle WebSocket upgrade for real-time message streaming
|
||||
async fn handle_api_socket(&self, request: &mut Request<Incoming>) -> Response<Full<Bytes>> {
|
||||
let argument = &request.uri().path()["/api/socket/".len()..];
|
||||
|
|
|
|||
|
|
@ -278,6 +278,27 @@ impl TestServeSession {
|
|||
Ok(deserialize_msgpack(&body).expect("Server returned malformed response"))
|
||||
}
|
||||
|
||||
/// Assert the in-memory tree exactly matches the filesystem.
|
||||
/// Calls the read-only `/api/validate-tree` endpoint which re-snapshots
|
||||
/// from disk and diffs against the current tree without applying corrections.
|
||||
/// Panics with drift counts if any discrepancy is found.
|
||||
pub fn assert_tree_fresh(&self) {
|
||||
let url = format!("http://localhost:{}/api/validate-tree", self.port);
|
||||
let body = reqwest::blocking::get(url)
|
||||
.expect("Failed to call /api/validate-tree")
|
||||
.bytes()
|
||||
.expect("Failed to read validate-tree response body");
|
||||
let report: librojo::TreeFreshnessReport =
|
||||
deserialize_msgpack(&body).expect("Failed to deserialize TreeFreshnessReport");
|
||||
|
||||
assert!(
|
||||
report.is_fresh,
|
||||
"Tree does not match filesystem: {} added, {} removed, {} updated \
|
||||
(validated in {:.1}ms)",
|
||||
report.added, report.removed, report.updated, report.elapsed_ms
|
||||
);
|
||||
}
|
||||
|
||||
/// Post to /api/write to simulate plugin syncback operations.
|
||||
/// Uses the library's WriteRequest type for proper serialization.
|
||||
/// Uses human-readable msgpack format to match server expectations.
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
//! (read back IDs, send updates) must use instances from the initial snapshot.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::{fs, thread};
|
||||
|
||||
|
|
@ -721,13 +721,17 @@ fn echo_suppression_prevents_redundant_patches() {
|
|||
let read_after = session.get_api_read(root_id).unwrap();
|
||||
let cursor_delta = read_after.message_cursor - initial_cursor;
|
||||
|
||||
// With proper echo suppression, the cursor should not advance excessively
|
||||
// With proper echo suppression, the cursor should not advance excessively.
|
||||
// For a single add: 0–3 messages (the suppressed echo plus potential
|
||||
// metadata/re-snapshot). Anything higher means echo events are leaking.
|
||||
assert!(
|
||||
cursor_delta < 10,
|
||||
"Message cursor should not advance excessively (delta={}), \
|
||||
indicating echo suppression is working",
|
||||
cursor_delta <= 3,
|
||||
"Echo suppression failure: cursor advanced by {} (expected <= 3). \
|
||||
VFS echo events are leaking through suppression.",
|
||||
cursor_delta
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2359,6 +2363,8 @@ fn echo_suppression_rapid_adds_10x() {
|
|||
"Cursor delta {} should be reasonable with echo suppression",
|
||||
cursor_delta
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2425,6 +2431,8 @@ fn echo_suppression_mixed_operations() {
|
|||
|
||||
// Verify removal
|
||||
assert_not_exists(&src.join("Echo.luau"), "Echo.luau after removal");
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2858,6 +2866,8 @@ fn watcher_rapid_source_edits_on_disk_10x() {
|
|||
}
|
||||
|
||||
poll_tree_source(&session, rs_id, "existing", "-- disk v10", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2873,6 +2883,8 @@ fn watcher_burst_writes_100x() {
|
|||
fs::write(&file_path, "-- burst final\nreturn {}").unwrap();
|
||||
|
||||
poll_tree_source(&session, rs_id, "existing", "-- burst final", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2903,6 +2915,8 @@ fn watcher_filesystem_rename_chain_10x() {
|
|||
original_content, content,
|
||||
"Content preserved through renames"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2924,6 +2938,8 @@ fn watcher_rename_with_content_change() {
|
|||
"-- atomic overwrite content",
|
||||
3000,
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2947,6 +2963,8 @@ fn watcher_delete_recreate_immediate() {
|
|||
"-- recreated immediately",
|
||||
3000,
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2970,6 +2988,8 @@ fn watcher_delete_recreate_cycle_5x() {
|
|||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -2990,6 +3010,8 @@ fn watcher_delete_recreate_different_content() {
|
|||
.unwrap();
|
||||
|
||||
poll_tree_source(&session, rs_id, "existing", "-- completely different", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3021,6 +3043,8 @@ fn watcher_edit_init_file() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||||
"ChildA should still exist after init edit"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3067,6 +3091,8 @@ fn watcher_init_type_cycling_10x() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildB"),
|
||||
"ChildB should survive init type cycling"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3081,6 +3107,8 @@ fn watcher_delete_init_file() {
|
|||
|
||||
// Without init file, directory becomes a Folder
|
||||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Folder", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3098,6 +3126,8 @@ fn watcher_replace_init_file_type() {
|
|||
fs::write(&new_init, &content).unwrap();
|
||||
|
||||
poll_tree_class(&session, rs_id, "DirModuleWithChildren", "Script", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3121,6 +3151,8 @@ fn watcher_standalone_to_directory_conversion() {
|
|||
fs::write(dir.join("ChildNew.luau"), "-- new child\nreturn {}").unwrap();
|
||||
|
||||
poll_tree_has_child(&session, rs_id, "StandaloneModule", "ChildNew", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3151,6 +3183,8 @@ fn watcher_directory_to_standalone_conversion() {
|
|||
"Standalone should have no children, found {}",
|
||||
child_count
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3199,6 +3233,8 @@ fn watcher_format_flip_flop_5x() {
|
|||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3227,6 +3263,8 @@ fn watcher_atomic_save_pattern() {
|
|||
3000,
|
||||
);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3254,6 +3292,8 @@ fn watcher_backup_rename_write_pattern() {
|
|||
if backup.exists() {
|
||||
fs::remove_file(&backup).unwrap();
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3281,6 +3321,8 @@ fn watcher_parent_directory_rename() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||||
"ChildA should be in renamed directory"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3293,6 +3335,8 @@ fn watcher_parent_directory_delete_all() {
|
|||
fs::remove_dir_all(src.join("DirModuleWithChildren")).unwrap();
|
||||
|
||||
poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3335,6 +3379,8 @@ fn watcher_filesystem_and_api_concurrent() {
|
|||
"Script should be updated via API, got: {}",
|
||||
script_content
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3364,6 +3410,8 @@ fn watcher_multi_file_simultaneous_edits() {
|
|||
5000,
|
||||
);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3832,6 +3880,8 @@ fn watcher_standalone_to_dir_then_rename() {
|
|||
|
||||
poll_tree_has_instance(&session, rs_id, "RenamedAfterConversion", 5000);
|
||||
poll_tree_no_instance(&session, rs_id, "StandaloneModule", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3858,6 +3908,8 @@ fn watcher_dir_to_standalone_then_rename() {
|
|||
|
||||
poll_tree_has_instance(&session, rs_id, "CollapsedAndRenamed", 5000);
|
||||
poll_tree_no_instance(&session, rs_id, "DirModuleWithChildren", 3000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3882,6 +3934,8 @@ fn watcher_standalone_to_dir_then_change_init_type() {
|
|||
fs::rename(dir.join("init.luau"), dir.join("init.server.luau")).unwrap();
|
||||
|
||||
poll_tree_class(&session, rs_id, "StandaloneModule", "Script", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3915,6 +3969,8 @@ fn watcher_init_delete_recreate_different_type() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||||
"ChildA should survive init type transition"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3942,6 +3998,8 @@ fn watcher_init_delete_children_survive() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildB"),
|
||||
"ChildB should exist after init deletion"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -3974,6 +4032,8 @@ fn watcher_format_flip_flop_with_rename() {
|
|||
fs::write(src.join("FlipRenamed.luau"), "-- collapsed back\nreturn {}").unwrap();
|
||||
|
||||
poll_tree_source(&session, rs_id, "FlipRenamed", "-- collapsed back", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4009,6 +4069,8 @@ fn watcher_rename_5_files_simultaneously() {
|
|||
for old in &names {
|
||||
poll_tree_no_instance(&session, rs_id, old, 3000);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4036,6 +4098,8 @@ fn watcher_delete_recreate_5_files_simultaneously() {
|
|||
for (i, name) in names.iter().enumerate() {
|
||||
poll_tree_source(&session, rs_id, name, &format!("-- recreated {}", i), 5000);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4063,6 +4127,8 @@ fn watcher_mixed_rename_edit_delete_5_files() {
|
|||
poll_tree_source(&session, rs_id, "Charlie", "-- charlie fresh", 5000);
|
||||
poll_tree_has_instance(&session, rs_id, "DeltaMoved", 5000);
|
||||
poll_tree_source(&session, rs_id, "Echo", "-- echo edited", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4476,6 +4542,8 @@ fn watcher_directory_rename_then_delete_init() {
|
|||
child_read.instances.values().any(|i| i.name == "ChildA"),
|
||||
"ChildA should survive rename + init deletion"
|
||||
);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4496,6 +4564,8 @@ fn watcher_directory_init_swap_and_rename() {
|
|||
|
||||
poll_tree_has_instance(&session, rs_id, "SwappedAndRenamed", 5000);
|
||||
poll_tree_class(&session, rs_id, "SwappedAndRenamed", "Script", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -4529,6 +4599,8 @@ fn watcher_directory_rapid_init_cycling_and_rename() {
|
|||
// Children intact
|
||||
assert_file_exists(&final_dir.join("ChildA.luau"), "ChildA in FinalCycled");
|
||||
assert_file_exists(&final_dir.join("ChildB.luau"), "ChildB in FinalCycled");
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -5593,3 +5665,293 @@ fn add_case_insensitive_colliding_instances() {
|
|||
);
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// VFS Staleness Tests
|
||||
//
|
||||
// These tests verify the in-memory tree stays in sync with the real filesystem
|
||||
// after various operation patterns that are known to stress the file watcher.
|
||||
// Every test ends with `session.assert_tree_fresh()` which re-snapshots from
|
||||
// disk and asserts zero drift.
|
||||
// ===========================================================================
|
||||
|
||||
/// Simulate a git-checkout-like bulk change: overwrite 20+ files simultaneously.
|
||||
#[test]
|
||||
fn stale_tree_bulk_filesystem_changes() {
|
||||
run_serve_test("stale_tree", |session, _redactions| {
|
||||
let src = session.path().join("src");
|
||||
|
||||
// Let the watcher fully start
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
|
||||
// Overwrite all initial files and add 15 more — no sleep between writes
|
||||
for i in 0..20 {
|
||||
let name = format!("bulk_{}.luau", i);
|
||||
fs::write(src.join(&name), format!("return \"bulk {}\"", i)).unwrap();
|
||||
}
|
||||
|
||||
// Wait for tree to settle by polling for the last written file
|
||||
poll_tree_has_instance(&session, get_rs_id(&session), "bulk_19", 5000);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/// After an API write (which triggers echo suppression), an external
|
||||
/// overwrite of the SAME file must still be picked up.
|
||||
#[test]
|
||||
fn stale_tree_after_api_write_then_external_edit() {
|
||||
run_serve_test("stale_tree", |session, _redactions| {
|
||||
let info = session.get_api_rojo().unwrap();
|
||||
let root_read = session.get_api_read(info.root_instance_id).unwrap();
|
||||
let (rs_id, _) = find_by_class(&root_read.instances, "ReplicatedStorage");
|
||||
let rs_read = session.get_api_read(rs_id).unwrap();
|
||||
let (a_id, _) = find_by_name(&rs_read.instances, "a");
|
||||
|
||||
// Step 1: API write updates Source (triggers suppression for the file)
|
||||
let mut props = UstrMap::default();
|
||||
props.insert(
|
||||
ustr("Source"),
|
||||
Some(Variant::String("return \"api wrote this\"".to_string())),
|
||||
);
|
||||
let update = InstanceUpdate {
|
||||
id: a_id,
|
||||
changed_name: None,
|
||||
changed_class_name: None,
|
||||
changed_properties: props,
|
||||
changed_metadata: None,
|
||||
};
|
||||
send_update(&session, &info.session_id, update);
|
||||
|
||||
// Step 2: External overwrite with DIFFERENT content
|
||||
let file_path = session.path().join("src").join("a.luau");
|
||||
fs::write(&file_path, "return \"external override\"").unwrap();
|
||||
|
||||
// Step 3: Wait for the external change to propagate
|
||||
poll_tree_source(&session, rs_id, "a", "external override", 5000);
|
||||
|
||||
// Step 4: Full tree must match filesystem exactly
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/// Delete all initial files and immediately create new ones with different names.
|
||||
/// Tests the debouncer coalescing edge case (Remove + Create in quick succession).
|
||||
#[test]
|
||||
fn stale_tree_rapid_delete_recreate_different_names() {
|
||||
run_serve_test("stale_tree", |session, _redactions| {
|
||||
let src = session.path().join("src");
|
||||
let rs_id = get_rs_id(&session);
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
|
||||
// Delete all 5 initial files
|
||||
for name in &["a.luau", "b.luau", "c.luau", "d.luau", "e.luau"] {
|
||||
let _ = fs::remove_file(src.join(name));
|
||||
}
|
||||
// Immediately create 5 new files with different names
|
||||
for i in 0..5 {
|
||||
fs::write(
|
||||
src.join(format!("new_{}.luau", i)),
|
||||
format!("return \"new {}\"", i),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Wait for the last new file to appear in the tree
|
||||
poll_tree_has_instance(&session, rs_id, "new_4", STRESS_POLL_TIMEOUT_MS);
|
||||
|
||||
// Old names must be gone
|
||||
for name in &["a", "b", "c", "d", "e"] {
|
||||
poll_tree_no_instance(&session, rs_id, name, STRESS_POLL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
/// Bulk restructure: delete a subdirectory, rename a file, change another file's content.
|
||||
/// Simulates the kind of multi-operation batch a VCS does.
|
||||
#[test]
|
||||
fn stale_tree_directory_restructure() {
|
||||
run_serve_test("stale_tree", |session, _redactions| {
|
||||
let src = session.path().join("src");
|
||||
let rs_id = get_rs_id(&session);
|
||||
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
|
||||
// Delete the sub/ directory
|
||||
let _ = fs::remove_dir_all(src.join("sub"));
|
||||
|
||||
// Rename a.luau → x.luau
|
||||
let _ = fs::rename(src.join("a.luau"), src.join("x.luau"));
|
||||
|
||||
// Change content of b.luau
|
||||
fs::write(src.join("b.luau"), "return \"restructured b\"").unwrap();
|
||||
|
||||
// Wait for changes to settle
|
||||
poll_tree_has_instance(&session, rs_id, "x", STRESS_POLL_TIMEOUT_MS);
|
||||
poll_tree_no_instance(&session, rs_id, "a", STRESS_POLL_TIMEOUT_MS);
|
||||
poll_tree_source(&session, rs_id, "b", "restructured b", STRESS_POLL_TIMEOUT_MS);
|
||||
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Chaos Fuzzer
|
||||
//
|
||||
// Slams the filesystem with random operations for 10 seconds straight, then
|
||||
// waits for the watcher to settle and asserts zero tree drift. This is the
|
||||
// nuclear option for finding watcher desync bugs.
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn fuzz_filesystem_chaos() {
|
||||
run_serve_test("stale_tree", |session, _redactions| {
|
||||
let src = session.path().join("src");
|
||||
|
||||
// Let the watcher fully start
|
||||
thread::sleep(Duration::from_secs(1));
|
||||
|
||||
// Track our own inventory
|
||||
let initial_files: Vec<PathBuf> = ["a.luau", "b.luau", "c.luau", "d.luau", "e.luau"]
|
||||
.iter()
|
||||
.map(|f| src.join(f))
|
||||
.collect();
|
||||
let mut known_files: Vec<PathBuf> = initial_files;
|
||||
let mut known_dirs: Vec<PathBuf> = vec![src.join("sub")];
|
||||
let mut file_counter = 0u64;
|
||||
let mut dir_counter = 0u64;
|
||||
|
||||
let start = Instant::now();
|
||||
let duration = Duration::from_secs(10);
|
||||
let mut op_count = 0u64;
|
||||
|
||||
while start.elapsed() < duration {
|
||||
let op = rand::random_range(0..9u32);
|
||||
match op {
|
||||
// 0: Create a new file
|
||||
0 => {
|
||||
file_counter += 1;
|
||||
let name = format!("fuzz_{}.luau", file_counter);
|
||||
let path = src.join(&name);
|
||||
if fs::write(&path, format!("return \"fuzz {}\"", file_counter)).is_ok() {
|
||||
known_files.push(path);
|
||||
}
|
||||
}
|
||||
// 1: Delete a random existing file
|
||||
1 => {
|
||||
if !known_files.is_empty() {
|
||||
let idx = rand::random_range(0..known_files.len());
|
||||
let path = known_files.swap_remove(idx);
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
// 2: Overwrite a random existing file
|
||||
2 => {
|
||||
if !known_files.is_empty() {
|
||||
let idx = rand::random_range(0..known_files.len());
|
||||
let path = &known_files[idx];
|
||||
let _ = fs::write(path, format!("return \"overwrite {}\"", op_count));
|
||||
}
|
||||
}
|
||||
// 3: Rename a random file
|
||||
3 => {
|
||||
if !known_files.is_empty() {
|
||||
file_counter += 1;
|
||||
let idx = rand::random_range(0..known_files.len());
|
||||
let old_path = known_files.swap_remove(idx);
|
||||
let new_name = format!("renamed_{}.luau", file_counter);
|
||||
let new_path = src.join(&new_name);
|
||||
if fs::rename(&old_path, &new_path).is_ok() {
|
||||
known_files.push(new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 4: Create a subdirectory + file inside it
|
||||
4 => {
|
||||
dir_counter += 1;
|
||||
let dir_name = format!("dir_{}", dir_counter);
|
||||
let dir_path = src.join(&dir_name);
|
||||
if fs::create_dir_all(&dir_path).is_ok() {
|
||||
known_dirs.push(dir_path.clone());
|
||||
file_counter += 1;
|
||||
let file_path = dir_path.join(format!("child_{}.luau", file_counter));
|
||||
if fs::write(&file_path, format!("return \"child {}\"", file_counter))
|
||||
.is_ok()
|
||||
{
|
||||
known_files.push(file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 5: Delete a random subdirectory (recursive)
|
||||
5 => {
|
||||
if !known_dirs.is_empty() {
|
||||
let idx = rand::random_range(0..known_dirs.len());
|
||||
let dir_path = known_dirs.swap_remove(idx);
|
||||
// Remove tracked files inside this dir
|
||||
known_files.retain(|f| !f.starts_with(&dir_path));
|
||||
let _ = fs::remove_dir_all(&dir_path);
|
||||
}
|
||||
}
|
||||
// 6: Rename a random directory
|
||||
6 => {
|
||||
if !known_dirs.is_empty() {
|
||||
dir_counter += 1;
|
||||
let idx = rand::random_range(0..known_dirs.len());
|
||||
let old_dir = known_dirs.swap_remove(idx);
|
||||
let new_name = format!("rdir_{}", dir_counter);
|
||||
let new_dir = src.join(&new_name);
|
||||
if fs::rename(&old_dir, &new_dir).is_ok() {
|
||||
// Update tracked file paths inside the renamed dir
|
||||
for f in &mut known_files {
|
||||
if f.starts_with(&old_dir) {
|
||||
let rel = f.strip_prefix(&old_dir).unwrap().to_path_buf();
|
||||
*f = new_dir.join(rel);
|
||||
}
|
||||
}
|
||||
known_dirs.push(new_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 7: Rapid delete+recreate same file (known edge case)
|
||||
7 => {
|
||||
if !known_files.is_empty() {
|
||||
let idx = rand::random_range(0..known_files.len());
|
||||
let path = known_files[idx].clone();
|
||||
let _ = fs::remove_file(&path);
|
||||
let _ = fs::write(&path, format!("return \"recreated {}\"", op_count));
|
||||
}
|
||||
}
|
||||
// 8: Double-write same file
|
||||
8 => {
|
||||
if !known_files.is_empty() {
|
||||
let idx = rand::random_range(0..known_files.len());
|
||||
let path = &known_files[idx];
|
||||
let _ = fs::write(path, format!("return \"write1 {}\"", op_count));
|
||||
let _ = fs::write(path, format!("return \"write2 {}\"", op_count));
|
||||
}
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
|
||||
op_count += 1;
|
||||
|
||||
// Tiny random delay (0-10ms) to vary timing pressure on the debouncer
|
||||
thread::sleep(Duration::from_millis(rand::random_range(0..10u64)));
|
||||
}
|
||||
|
||||
eprintln!(
|
||||
"Chaos fuzzer completed {} operations in {:?}",
|
||||
op_count,
|
||||
start.elapsed()
|
||||
);
|
||||
|
||||
// Wait for VFS watcher + ChangeProcessor to fully settle
|
||||
thread::sleep(Duration::from_secs(3));
|
||||
|
||||
// THE CRITICAL ASSERTION: tree must match filesystem after all that chaos
|
||||
session.assert_tree_fresh();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue