From 546bd2f6ed1db03ef4af87313861f53ab4ae1d61 Mon Sep 17 00:00:00 2001 From: Mario Alejandro Montoya Cortes Date: Fri, 8 Aug 2025 13:16:10 -0500 Subject: [PATCH 1/3] Smoke test for Rust quickstart app #2100 --- smoketests/__init__.py | 2 +- smoketests/tests/quickstart.py | 88 ++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 smoketests/tests/quickstart.py diff --git a/smoketests/__init__.py b/smoketests/__init__.py index ed9d16cac69..356c3300a03 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -22,7 +22,7 @@ SPACETIME_BIN = STDB_DIR / ("target/debug/spacetime" + exe_suffix) TEMPLATE_TARGET_DIR = STDB_DIR / "target/_stdbsmoketests" STDB_CONFIG = TEST_DIR / "config.toml" - +MODULES_DIR = STDB_DIR / "modules" # the contents of files for the base smoketest project template TEMPLATE_LIB_RS = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/lib._rs").read() TEMPLATE_CARGO_TOML = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/Cargo._toml").read() diff --git a/smoketests/tests/quickstart.py b/smoketests/tests/quickstart.py new file mode 100644 index 00000000000..0adf441058c --- /dev/null +++ b/smoketests/tests/quickstart.py @@ -0,0 +1,88 @@ +import logging +import re +import tempfile +from pathlib import Path + +from .. import Smoketest, MODULES_DIR, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML, TEST_DIR + +DEPENDENCIES = """ +log = "0.4" +hex= "0.4" +""" +sdk_path = (STDB_DIR / "crates/sdk").absolute() +escaped_sdk_path = str(sdk_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml +DEPENDENCIES_TOML = f'spacetimedb-sdk = {{ path = "{escaped_sdk_path}" }}' + DEPENDENCIES + +# The quickstart `main.rs` use a `repl` loop to read user input, so we need to replace it for the smoketest. +TEST = """ +fn user_input_direct(ctx: &DbConnection) { + let mut line = String::new(); + std::io::stdin().read_line(&mut line).expect("Failed to read from stdin."); + if let Some(name) = line.strip_prefix("/name ") { + ctx.reducers.set_name(name.to_string()).unwrap(); + } else { + ctx.reducers.send_message(line).unwrap(); + } + std::thread::sleep(std::time::Duration::from_secs(1)); + std::process::exit(0); +} +""" + + +def parse_quickstart(): + """This method is used to parse the documentation of `docs/docs/sdks/rust/quickstart.md`.""" + + doc_path = STDB_DIR / "docs/docs/sdks/rust" / "quickstart.md" + content = open(doc_path, "r").read() + + # Extract all Rust code blocks from the documentation. + # This will replicate the steps in the quickstart guide, so if it fails the quickstart guide is broken. + code_blocks = re.findall(r"```rust\n(.*?)\n```", content, re.DOTALL) + + return "\n".join(code_blocks).replace("user_input_loop(&ctx)", "user_input_direct(&ctx)") + "\n" + TEST + + +class Quickstart(Smoketest): + AUTOPUBLISH = False + MODULE_CODE = "" + + def check(self, input_cmd: str, client_path: Path, contains: str): + output = run_cmd("cargo", "run", input=input_cmd, cwd=client_path, capture_stderr=True, text=True) + self.assertIn(contains, output) + + def test_quickstart_rs(self): + """This test is designed to run the quickstart guide for the Rust SDK.""" + self.project_path = MODULES_DIR / "quickstart-chat" + self.config_path = self.project_path / "config.toml" + self.publish_module("quickstart-chat", capture_stderr=True, clear=True) + client_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) + logging.info(f"Generating client code in {client_path}...") + # Create a cargo project structure + run_cmd( + "cargo", "new", "--bin", "quickstart_chat_client", + cwd=client_path, capture_stderr=True + ) + client_path = client_path / "quickstart_chat_client" + + open(client_path / "Cargo.toml", "a").write(DEPENDENCIES_TOML) + + # Replay the quickstart guide steps + main = parse_quickstart() + open(client_path / "src" / "main.rs", "w").write(main) + self.spacetime( + "generate", + "--lang", "rust", + "--out-dir", client_path / "src" / "module_bindings", + "--project-path", self.project_path, + capture_stderr=True + ) + logging.info(f"Client code generated in {client_path}.") + run_cmd( + "cargo", "build", + cwd=client_path, capture_stderr=True + ) + + # Replay the quickstart guide steps for test the client + self.check("", client_path, "connected") + self.check("/name Alice", client_path, "Alice") + self.check("Hello World", client_path, "Hello World") From d57012cb4847f3ceacad66391c4dc05187924897 Mon Sep 17 00:00:00 2001 From: Mario Alejandro Montoya Cortes Date: Tue, 12 Aug 2025 10:20:13 -0500 Subject: [PATCH 2/3] Smoke test for C# quickstart app --- smoketests/__init__.py | 2 +- smoketests/tests/quickstart.py | 280 ++++++++++++++++++++++++++------- 2 files changed, 223 insertions(+), 59 deletions(-) diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 356c3300a03..ed9d16cac69 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -22,7 +22,7 @@ SPACETIME_BIN = STDB_DIR / ("target/debug/spacetime" + exe_suffix) TEMPLATE_TARGET_DIR = STDB_DIR / "target/_stdbsmoketests" STDB_CONFIG = TEST_DIR / "config.toml" -MODULES_DIR = STDB_DIR / "modules" + # the contents of files for the base smoketest project template TEMPLATE_LIB_RS = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/lib._rs").read() TEMPLATE_CARGO_TOML = open(STDB_DIR / "crates/cli/src/subcommands/project/rust/Cargo._toml").read() diff --git a/smoketests/tests/quickstart.py b/smoketests/tests/quickstart.py index 0adf441058c..f54b7be705c 100644 --- a/smoketests/tests/quickstart.py +++ b/smoketests/tests/quickstart.py @@ -1,20 +1,152 @@ import logging import re -import tempfile +import shutil from pathlib import Path +import tempfile -from .. import Smoketest, MODULES_DIR, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML, TEST_DIR +import smoketests +from .. import Smoketest, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML -DEPENDENCIES = """ -log = "0.4" -hex= "0.4" -""" -sdk_path = (STDB_DIR / "crates/sdk").absolute() -escaped_sdk_path = str(sdk_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml -DEPENDENCIES_TOML = f'spacetimedb-sdk = {{ path = "{escaped_sdk_path}" }}' + DEPENDENCIES -# The quickstart `main.rs` use a `repl` loop to read user input, so we need to replace it for the smoketest. -TEST = """ +def _write_file(path: Path, content: str): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + + +def _append_to_file(path: Path, content: str): + with open(path, "a", encoding="utf-8") as f: + f.write(content) + + +def _parse_quickstart(doc_path: Path, language: str) -> str: + """Extract code blocks from `quickstart.md` docs. + This will replicate the steps in the quickstart guide, so if it fails the quickstart guide is broken. + """ + content = Path(doc_path).read_text() + blocks = re.findall(rf"```{language}\n(.*?)\n```", content, re.DOTALL) + + end = "" + if language == "csharp": + found = False + filtered_blocks = [] + for block in blocks: + # The doc first create an empy class Module, so we need to fixup the closing + if "partial class Module" in block: + block = block.replace("}", "") + end = "\n}" + # Remove the first `OnConnected` block, which body is later updated + if "OnConnected(DbConnection conn" in block and not found: + found = True + continue + filtered_blocks.append(block) + blocks = filtered_blocks + # So we could have a different db for each language + return "\n".join(blocks).replace("quickstart-chat", f"quickstart-chat-{language}") + end + + +def _dotnet_add_package(project_path: Path, package_name: str, source_path: Path): + """Add a local NuGet package to a .NET project""" + sources = run_cmd("dotnet", "nuget", "list", "source", cwd=project_path, capture_stderr=True) + # Is the source already added? + if package_name in sources: + run_cmd("dotnet", "nuget", "remove", "source", package_name, cwd=project_path, capture_stderr=True) + run_cmd("dotnet", "nuget", "add", "source", source_path, "--name", package_name, cwd=project_path, + capture_stderr=True) + run_cmd("dotnet", "add", "package", package_name, cwd=project_path, capture_stderr=True) + + +class BaseQuickstart(Smoketest): + AUTOPUBLISH = False + MODULE_CODE = "" + + lang = None + server_doc = None + client_doc = None + server_file = None + client_file = None + module_bindings = None + extra_code = None + replacements = {} + connected_str = None + run_cmd = [] + build_cmd = [] + + def project_init(self, path: Path): + raise NotImplementedError + + def sdk_setup(self, path: Path): + raise NotImplementedError + + def _publish(self) -> Path: + base_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) + + server_path = base_path / "server" + self.project_path = server_path + self.config_path = server_path / "config.toml" + + self.generate_server(server_path) + self.publish_module(f"quickstart-chat-{self.lang}", capture_stderr=True, clear=True) + return base_path / "client" + + def generate_server(self, server_path: Path): + """Generate the server code from the quickstart documentation.""" + logging.info(f"Generating server code {self.lang}: {server_path}...") + self.spacetime("init", "--lang", self.lang, server_path, capture_stderr=True) + # Replay the quickstart guide steps + _write_file(server_path / self.server_file, _parse_quickstart(self.server_doc, self.lang)) + self.server_postprocess(server_path) + self.spacetime("build", "-d", "-p", server_path, capture_stderr=True) + + def server_postprocess(self, server_path: Path): + """Optional per-language hook.""" + pass + + def check(self, input_cmd: str, client_path: Path, contains: str): + """Run the client command and check if the output contains the expected string.""" + output = run_cmd(*self.run_cmd, input=input_cmd, cwd=client_path, capture_stderr=True, text=True) + print(f"Output for {self.lang} client:\n{output}") + self.assertIn(contains, output) + + def _test_quickstart(self): + """Run the quickstart client.""" + client_path = self._publish() + self.project_init(client_path) + self.sdk_setup(client_path) + + run_cmd(*self.build_cmd, cwd=client_path, capture_stderr=True) + + self.spacetime( + "generate", "--lang", self.lang, + "--out-dir", client_path / self.module_bindings, + "--project-path", self.project_path, capture_stderr=True + ) + # Replay the quickstart guide steps + main = _parse_quickstart(self.client_doc, self.lang) + for src, dst in self.replacements.items(): + main = main.replace(src, dst) + main += "\n" + self.extra_code + _write_file(client_path / self.client_file, main) + + self.check("", client_path, self.connected_str) + self.check("/name Alice", client_path, "Alice") + self.check("Hello World", client_path, "Hello World") + + +class Rust(BaseQuickstart): + lang = "rust" + server_doc = STDB_DIR / "docs/docs/modules/rust/quickstart.md" + client_doc = STDB_DIR / "docs/docs/sdks/rust/quickstart.md" + server_file = "src/lib.rs" + client_file = "src/main.rs" + module_bindings = "src/module_bindings" + run_cmd = ["cargo", "run"] + build_cmd = ["cargo", "build"] + + # Replace the interactive user input to allow direct testing + replacements = { + "user_input_loop(&ctx)": "user_input_direct(&ctx)" + } + extra_code = """ fn user_input_direct(ctx: &DbConnection) { let mut line = String::new(); std::io::stdin().read_line(&mut line).expect("Failed to read from stdin."); @@ -27,62 +159,94 @@ std::process::exit(0); } """ + connected_str = "connected" + def project_init(self, path: Path): + run_cmd("cargo", "new", "--bin", "--name", "quickstart_chat_client", "client", cwd=path.parent, + capture_stderr=True) -def parse_quickstart(): - """This method is used to parse the documentation of `docs/docs/sdks/rust/quickstart.md`.""" + def sdk_setup(self, path: Path): + sdk_rust_path = (STDB_DIR / "crates/sdk").absolute() + sdk_rust_toml_escaped = str(sdk_rust_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml + sdk_rust_toml = f'spacetimedb-sdk = {{ path = "{sdk_rust_toml_escaped}" }}\nlog = "0.4"\nhex = "0.4"\n' + _append_to_file(path / "Cargo.toml", sdk_rust_toml) - doc_path = STDB_DIR / "docs/docs/sdks/rust" / "quickstart.md" - content = open(doc_path, "r").read() + def server_postprocess(self, server_path: Path): + _write_file(server_path / "Cargo.toml", self.cargo_manifest(TEMPLATE_CARGO_TOML)) - # Extract all Rust code blocks from the documentation. - # This will replicate the steps in the quickstart guide, so if it fails the quickstart guide is broken. - code_blocks = re.findall(r"```rust\n(.*?)\n```", content, re.DOTALL) + def test_quickstart(self): + """Run the Rust quickstart guides for server and client.""" + self._test_quickstart() - return "\n".join(code_blocks).replace("user_input_loop(&ctx)", "user_input_direct(&ctx)") + "\n" + TEST +class CSharp(BaseQuickstart): + lang = "csharp" + server_doc = STDB_DIR / "docs/docs/modules/c-sharp/quickstart.md" + client_doc = STDB_DIR / "docs/docs/sdks/c-sharp/quickstart.md" + server_file = "Lib.cs" + client_file = "Program.cs" + module_bindings = "module_bindings" + run_cmd = ["dotnet", "run"] + build_cmd = ["dotnet", "build"] -class Quickstart(Smoketest): - AUTOPUBLISH = False - MODULE_CODE = "" + # Replace the interactive user input to allow direct testing + replacements = { + "InputLoop();": "UserInputDirect();", + ".OnConnect(OnConnected)": ".OnConnect(OnConnectedSignal)", + ".OnConnectError(OnConnectError)": ".OnConnectError(OnConnectErrorSignal)", + "Main();": "" # To put the main function at the end so it can see the new functions + } + # So we can wait for the connection to be established... + extra_code = """ +var connectedEvent = new ManualResetEventSlim(false); +var connectionFailed = new ManualResetEventSlim(false); +void OnConnectErrorSignal(Exception e) +{ + OnConnectError(e); + connectionFailed.Set(); +} +void OnConnectedSignal(DbConnection conn, Identity identity, string authToken) +{ + OnConnected(conn, identity, authToken); + connectedEvent.Set(); +} - def check(self, input_cmd: str, client_path: Path, contains: str): - output = run_cmd("cargo", "run", input=input_cmd, cwd=client_path, capture_stderr=True, text=True) - self.assertIn(contains, output) +void UserInputDirect() { + string? line = Console.In.ReadToEnd()?.Trim(); + if (line == null) Environment.Exit(0); + + if (!WaitHandle.WaitAny( + new[] { connectedEvent.WaitHandle, connectionFailed.WaitHandle }, + TimeSpan.FromSeconds(5) + ).Equals(0)) + { + Console.WriteLine("Failed to connect to server within timeout."); + Environment.Exit(1); + } + + if (line.StartsWith("/name ")) { + input_queue.Enqueue(("name", line[6..])); + } else { + input_queue.Enqueue(("message", line)); + } + Thread.Sleep(1000); +} +Main(); +""" + connected_str = "Connected" - def test_quickstart_rs(self): - """This test is designed to run the quickstart guide for the Rust SDK.""" - self.project_path = MODULES_DIR / "quickstart-chat" - self.config_path = self.project_path / "config.toml" - self.publish_module("quickstart-chat", capture_stderr=True, clear=True) - client_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) - logging.info(f"Generating client code in {client_path}...") - # Create a cargo project structure - run_cmd( - "cargo", "new", "--bin", "quickstart_chat_client", - cwd=client_path, capture_stderr=True - ) - client_path = client_path / "quickstart_chat_client" + def project_init(self, path: Path): + run_cmd("dotnet", "new", "console", "--name", "QuickstartChatClient", "--output", path, capture_stderr=True) - open(client_path / "Cargo.toml", "a").write(DEPENDENCIES_TOML) + def sdk_setup(self, path: Path): + _dotnet_add_package(path, "SpacetimeDB.ClientSDK", (STDB_DIR / "sdks/csharp").absolute()) - # Replay the quickstart guide steps - main = parse_quickstart() - open(client_path / "src" / "main.rs", "w").write(main) - self.spacetime( - "generate", - "--lang", "rust", - "--out-dir", client_path / "src" / "module_bindings", - "--project-path", self.project_path, - capture_stderr=True - ) - logging.info(f"Client code generated in {client_path}.") - run_cmd( - "cargo", "build", - cwd=client_path, capture_stderr=True - ) + def server_postprocess(self, server_path: Path): + _dotnet_add_package(server_path, "SpacetimeDB.Runtime", + (STDB_DIR / "crates/bindings-csharp/Runtime").absolute()) - # Replay the quickstart guide steps for test the client - self.check("", client_path, "connected") - self.check("/name Alice", client_path, "Alice") - self.check("Hello World", client_path, "Hello World") + def test_quickstart(self): + """Run the C# quickstart guides for server and client.""" + if not smoketests.HAVE_DOTNET: + self.skipTest("C# SDK requires .NET to be installed.") + self._test_quickstart() From 9593c78a42134218f18306e37eab7f63f187d54b Mon Sep 17 00:00:00 2001 From: Mario Alejandro Montoya Cortes Date: Tue, 26 Aug 2025 12:32:04 -0500 Subject: [PATCH 3/3] Update path of Rust SDK --- smoketests/tests/quickstart.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smoketests/tests/quickstart.py b/smoketests/tests/quickstart.py index f54b7be705c..7cf80c7bb41 100644 --- a/smoketests/tests/quickstart.py +++ b/smoketests/tests/quickstart.py @@ -79,7 +79,6 @@ def sdk_setup(self, path: Path): def _publish(self) -> Path: base_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) - server_path = base_path / "server" self.project_path = server_path self.config_path = server_path / "config.toml" @@ -92,6 +91,7 @@ def generate_server(self, server_path: Path): """Generate the server code from the quickstart documentation.""" logging.info(f"Generating server code {self.lang}: {server_path}...") self.spacetime("init", "--lang", self.lang, server_path, capture_stderr=True) + shutil.copy2(STDB_DIR / "rust-toolchain.toml", server_path) # Replay the quickstart guide steps _write_file(server_path / self.server_file, _parse_quickstart(self.server_doc, self.lang)) self.server_postprocess(server_path) @@ -166,7 +166,7 @@ def project_init(self, path: Path): capture_stderr=True) def sdk_setup(self, path: Path): - sdk_rust_path = (STDB_DIR / "crates/sdk").absolute() + sdk_rust_path = (STDB_DIR / "sdks/rust").absolute() sdk_rust_toml_escaped = str(sdk_rust_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml sdk_rust_toml = f'spacetimedb-sdk = {{ path = "{sdk_rust_toml_escaped}" }}\nlog = "0.4"\nhex = "0.4"\n' _append_to_file(path / "Cargo.toml", sdk_rust_toml)