Skip to content

Commit 6bd815f

Browse files
authored
Smoke test for Rust quickstart-chat app #2100 (#3155)
# Description of Changes Closes #2100 Replay the `quickstart-chat` guide steps for the `Rust/C#` client & server, and confirm the connection and `set_name`, `send_message` commands. # Expected complexity level and risk 1 # Testing - [x] Added a smoke test
1 parent eb105ad commit 6bd815f

File tree

1 file changed

+252
-0
lines changed

1 file changed

+252
-0
lines changed

smoketests/tests/quickstart.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
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

Comments
 (0)