|
| 1 | +import logging |
| 2 | +import re |
| 3 | +import shutil |
| 4 | +from pathlib import Path |
| 5 | +import tempfile |
| 6 | + |
| 7 | +import smoketests |
| 8 | +from .. import Smoketest, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML |
| 9 | + |
| 10 | + |
| 11 | +def _write_file(path: Path, content: str): |
| 12 | + path.parent.mkdir(parents=True, exist_ok=True) |
| 13 | + path.write_text(content) |
| 14 | + |
| 15 | + |
| 16 | +def _append_to_file(path: Path, content: str): |
| 17 | + with open(path, "a", encoding="utf-8") as f: |
| 18 | + f.write(content) |
| 19 | + |
| 20 | + |
| 21 | +def _parse_quickstart(doc_path: Path, language: str) -> str: |
| 22 | + """Extract code blocks from `quickstart.md` docs. |
| 23 | + This will replicate the steps in the quickstart guide, so if it fails the quickstart guide is broken. |
| 24 | + """ |
| 25 | + content = Path(doc_path).read_text() |
| 26 | + blocks = re.findall(rf"```{language}\n(.*?)\n```", content, re.DOTALL) |
| 27 | + |
| 28 | + end = "" |
| 29 | + if language == "csharp": |
| 30 | + found = False |
| 31 | + filtered_blocks = [] |
| 32 | + for block in blocks: |
| 33 | + # The doc first create an empy class Module, so we need to fixup the closing |
| 34 | + if "partial class Module" in block: |
| 35 | + block = block.replace("}", "") |
| 36 | + end = "\n}" |
| 37 | + # Remove the first `OnConnected` block, which body is later updated |
| 38 | + if "OnConnected(DbConnection conn" in block and not found: |
| 39 | + found = True |
| 40 | + continue |
| 41 | + filtered_blocks.append(block) |
| 42 | + blocks = filtered_blocks |
| 43 | + # So we could have a different db for each language |
| 44 | + return "\n".join(blocks).replace("quickstart-chat", f"quickstart-chat-{language}") + end |
| 45 | + |
| 46 | + |
| 47 | +def _dotnet_add_package(project_path: Path, package_name: str, source_path: Path): |
| 48 | + """Add a local NuGet package to a .NET project""" |
| 49 | + sources = run_cmd("dotnet", "nuget", "list", "source", cwd=project_path, capture_stderr=True) |
| 50 | + # Is the source already added? |
| 51 | + if package_name in sources: |
| 52 | + run_cmd("dotnet", "nuget", "remove", "source", package_name, cwd=project_path, capture_stderr=True) |
| 53 | + run_cmd("dotnet", "nuget", "add", "source", source_path, "--name", package_name, cwd=project_path, |
| 54 | + capture_stderr=True) |
| 55 | + run_cmd("dotnet", "add", "package", package_name, cwd=project_path, capture_stderr=True) |
| 56 | + |
| 57 | + |
| 58 | +class BaseQuickstart(Smoketest): |
| 59 | + AUTOPUBLISH = False |
| 60 | + MODULE_CODE = "" |
| 61 | + |
| 62 | + lang = None |
| 63 | + server_doc = None |
| 64 | + client_doc = None |
| 65 | + server_file = None |
| 66 | + client_file = None |
| 67 | + module_bindings = None |
| 68 | + extra_code = None |
| 69 | + replacements = {} |
| 70 | + connected_str = None |
| 71 | + run_cmd = [] |
| 72 | + build_cmd = [] |
| 73 | + |
| 74 | + def project_init(self, path: Path): |
| 75 | + raise NotImplementedError |
| 76 | + |
| 77 | + def sdk_setup(self, path: Path): |
| 78 | + raise NotImplementedError |
| 79 | + |
| 80 | + def _publish(self) -> Path: |
| 81 | + base_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) |
| 82 | + server_path = base_path / "server" |
| 83 | + self.project_path = server_path |
| 84 | + self.config_path = server_path / "config.toml" |
| 85 | + |
| 86 | + self.generate_server(server_path) |
| 87 | + self.publish_module(f"quickstart-chat-{self.lang}", capture_stderr=True, clear=True) |
| 88 | + return base_path / "client" |
| 89 | + |
| 90 | + def generate_server(self, server_path: Path): |
| 91 | + """Generate the server code from the quickstart documentation.""" |
| 92 | + logging.info(f"Generating server code {self.lang}: {server_path}...") |
| 93 | + self.spacetime("init", "--lang", self.lang, server_path, capture_stderr=True) |
| 94 | + shutil.copy2(STDB_DIR / "rust-toolchain.toml", server_path) |
| 95 | + # Replay the quickstart guide steps |
| 96 | + _write_file(server_path / self.server_file, _parse_quickstart(self.server_doc, self.lang)) |
| 97 | + self.server_postprocess(server_path) |
| 98 | + self.spacetime("build", "-d", "-p", server_path, capture_stderr=True) |
| 99 | + |
| 100 | + def server_postprocess(self, server_path: Path): |
| 101 | + """Optional per-language hook.""" |
| 102 | + pass |
| 103 | + |
| 104 | + def check(self, input_cmd: str, client_path: Path, contains: str): |
| 105 | + """Run the client command and check if the output contains the expected string.""" |
| 106 | + output = run_cmd(*self.run_cmd, input=input_cmd, cwd=client_path, capture_stderr=True, text=True) |
| 107 | + print(f"Output for {self.lang} client:\n{output}") |
| 108 | + self.assertIn(contains, output) |
| 109 | + |
| 110 | + def _test_quickstart(self): |
| 111 | + """Run the quickstart client.""" |
| 112 | + client_path = self._publish() |
| 113 | + self.project_init(client_path) |
| 114 | + self.sdk_setup(client_path) |
| 115 | + |
| 116 | + run_cmd(*self.build_cmd, cwd=client_path, capture_stderr=True) |
| 117 | + |
| 118 | + self.spacetime( |
| 119 | + "generate", "--lang", self.lang, |
| 120 | + "--out-dir", client_path / self.module_bindings, |
| 121 | + "--project-path", self.project_path, capture_stderr=True |
| 122 | + ) |
| 123 | + # Replay the quickstart guide steps |
| 124 | + main = _parse_quickstart(self.client_doc, self.lang) |
| 125 | + for src, dst in self.replacements.items(): |
| 126 | + main = main.replace(src, dst) |
| 127 | + main += "\n" + self.extra_code |
| 128 | + _write_file(client_path / self.client_file, main) |
| 129 | + |
| 130 | + self.check("", client_path, self.connected_str) |
| 131 | + self.check("/name Alice", client_path, "Alice") |
| 132 | + self.check("Hello World", client_path, "Hello World") |
| 133 | + |
| 134 | + |
| 135 | +class Rust(BaseQuickstart): |
| 136 | + lang = "rust" |
| 137 | + server_doc = STDB_DIR / "docs/docs/modules/rust/quickstart.md" |
| 138 | + client_doc = STDB_DIR / "docs/docs/sdks/rust/quickstart.md" |
| 139 | + server_file = "src/lib.rs" |
| 140 | + client_file = "src/main.rs" |
| 141 | + module_bindings = "src/module_bindings" |
| 142 | + run_cmd = ["cargo", "run"] |
| 143 | + build_cmd = ["cargo", "build"] |
| 144 | + |
| 145 | + # Replace the interactive user input to allow direct testing |
| 146 | + replacements = { |
| 147 | + "user_input_loop(&ctx)": "user_input_direct(&ctx)" |
| 148 | + } |
| 149 | + extra_code = """ |
| 150 | +fn user_input_direct(ctx: &DbConnection) { |
| 151 | + let mut line = String::new(); |
| 152 | + std::io::stdin().read_line(&mut line).expect("Failed to read from stdin."); |
| 153 | + if let Some(name) = line.strip_prefix("/name ") { |
| 154 | + ctx.reducers.set_name(name.to_string()).unwrap(); |
| 155 | + } else { |
| 156 | + ctx.reducers.send_message(line).unwrap(); |
| 157 | + } |
| 158 | + std::thread::sleep(std::time::Duration::from_secs(1)); |
| 159 | + std::process::exit(0); |
| 160 | +} |
| 161 | +""" |
| 162 | + connected_str = "connected" |
| 163 | + |
| 164 | + def project_init(self, path: Path): |
| 165 | + run_cmd("cargo", "new", "--bin", "--name", "quickstart_chat_client", "client", cwd=path.parent, |
| 166 | + capture_stderr=True) |
| 167 | + |
| 168 | + def sdk_setup(self, path: Path): |
| 169 | + sdk_rust_path = (STDB_DIR / "sdks/rust").absolute() |
| 170 | + sdk_rust_toml_escaped = str(sdk_rust_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml |
| 171 | + sdk_rust_toml = f'spacetimedb-sdk = {{ path = "{sdk_rust_toml_escaped}" }}\nlog = "0.4"\nhex = "0.4"\n' |
| 172 | + _append_to_file(path / "Cargo.toml", sdk_rust_toml) |
| 173 | + |
| 174 | + def server_postprocess(self, server_path: Path): |
| 175 | + _write_file(server_path / "Cargo.toml", self.cargo_manifest(TEMPLATE_CARGO_TOML)) |
| 176 | + |
| 177 | + def test_quickstart(self): |
| 178 | + """Run the Rust quickstart guides for server and client.""" |
| 179 | + self._test_quickstart() |
| 180 | + |
| 181 | + |
| 182 | +class CSharp(BaseQuickstart): |
| 183 | + lang = "csharp" |
| 184 | + server_doc = STDB_DIR / "docs/docs/modules/c-sharp/quickstart.md" |
| 185 | + client_doc = STDB_DIR / "docs/docs/sdks/c-sharp/quickstart.md" |
| 186 | + server_file = "Lib.cs" |
| 187 | + client_file = "Program.cs" |
| 188 | + module_bindings = "module_bindings" |
| 189 | + run_cmd = ["dotnet", "run"] |
| 190 | + build_cmd = ["dotnet", "build"] |
| 191 | + |
| 192 | + # Replace the interactive user input to allow direct testing |
| 193 | + replacements = { |
| 194 | + "InputLoop();": "UserInputDirect();", |
| 195 | + ".OnConnect(OnConnected)": ".OnConnect(OnConnectedSignal)", |
| 196 | + ".OnConnectError(OnConnectError)": ".OnConnectError(OnConnectErrorSignal)", |
| 197 | + "Main();": "" # To put the main function at the end so it can see the new functions |
| 198 | + } |
| 199 | + # So we can wait for the connection to be established... |
| 200 | + extra_code = """ |
| 201 | +var connectedEvent = new ManualResetEventSlim(false); |
| 202 | +var connectionFailed = new ManualResetEventSlim(false); |
| 203 | +void OnConnectErrorSignal(Exception e) |
| 204 | +{ |
| 205 | + OnConnectError(e); |
| 206 | + connectionFailed.Set(); |
| 207 | +} |
| 208 | +void OnConnectedSignal(DbConnection conn, Identity identity, string authToken) |
| 209 | +{ |
| 210 | + OnConnected(conn, identity, authToken); |
| 211 | + connectedEvent.Set(); |
| 212 | +} |
| 213 | +
|
| 214 | +void UserInputDirect() { |
| 215 | + string? line = Console.In.ReadToEnd()?.Trim(); |
| 216 | + if (line == null) Environment.Exit(0); |
| 217 | + |
| 218 | + if (!WaitHandle.WaitAny( |
| 219 | + new[] { connectedEvent.WaitHandle, connectionFailed.WaitHandle }, |
| 220 | + TimeSpan.FromSeconds(5) |
| 221 | + ).Equals(0)) |
| 222 | + { |
| 223 | + Console.WriteLine("Failed to connect to server within timeout."); |
| 224 | + Environment.Exit(1); |
| 225 | + } |
| 226 | + |
| 227 | + if (line.StartsWith("/name ")) { |
| 228 | + input_queue.Enqueue(("name", line[6..])); |
| 229 | + } else { |
| 230 | + input_queue.Enqueue(("message", line)); |
| 231 | + } |
| 232 | + Thread.Sleep(1000); |
| 233 | +} |
| 234 | +Main(); |
| 235 | +""" |
| 236 | + connected_str = "Connected" |
| 237 | + |
| 238 | + def project_init(self, path: Path): |
| 239 | + run_cmd("dotnet", "new", "console", "--name", "QuickstartChatClient", "--output", path, capture_stderr=True) |
| 240 | + |
| 241 | + def sdk_setup(self, path: Path): |
| 242 | + _dotnet_add_package(path, "SpacetimeDB.ClientSDK", (STDB_DIR / "sdks/csharp").absolute()) |
| 243 | + |
| 244 | + def server_postprocess(self, server_path: Path): |
| 245 | + _dotnet_add_package(server_path, "SpacetimeDB.Runtime", |
| 246 | + (STDB_DIR / "crates/bindings-csharp/Runtime").absolute()) |
| 247 | + |
| 248 | + def test_quickstart(self): |
| 249 | + """Run the C# quickstart guides for server and client.""" |
| 250 | + if not smoketests.HAVE_DOTNET: |
| 251 | + self.skipTest("C# SDK requires .NET to be installed.") |
| 252 | + self._test_quickstart() |
0 commit comments