diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 052126c865f..1a236c42b66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,6 +50,17 @@ jobs: - uses: actions/setup-dotnet@v4 with: global-json-file: global.json + + # nodejs and pnpm are required for the typescript quickstart smoketest + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 18 + + - uses: pnpm/action-setup@v4 + with: + run_install: true + - name: Install psql (Windows) if: runner.os == 'Windows' run: choco install psql -y --no-progress diff --git a/smoketests/__init__.py b/smoketests/__init__.py index 6f2c5b39247..71e8cf46576 100644 --- a/smoketests/__init__.py +++ b/smoketests/__init__.py @@ -14,6 +14,7 @@ import logging import http.client import tomllib +import functools # miscellaneous file paths TEST_DIR = Path(__file__).parent @@ -28,6 +29,7 @@ TEMPLATE_CARGO_TOML = open(STDB_DIR / "crates/cli/templates/basic-rust/server/Cargo.toml").read() bindings_path = (STDB_DIR / "crates/bindings").absolute() escaped_bindings_path = str(bindings_path).replace('\\', '\\\\\\\\') # double escape for re.sub + toml +TYPESCRIPT_BINDINGS_PATH = (STDB_DIR / "crates/bindings-typescript").absolute() TEMPLATE_CARGO_TOML = (re.compile(r"^spacetimedb\s*=.*$", re.M) \ .sub(f'spacetimedb = {{ path = "{escaped_bindings_path}", features = {{features}} }}', TEMPLATE_CARGO_TOML)) @@ -170,6 +172,21 @@ def run_cmd(*args, capture_stderr=True, check=True, full_output=False, cmd_name= output.check_returncode() return output if full_output else output.stdout +@functools.cache +def pnpm_path(): + pnpm = shutil.which("pnpm") + if not pnpm: + raise Exception("pnpm not installed") + return pnpm + +def pnpm(*args, **kwargs): + return run_cmd(pnpm_path(), *args, **kwargs) + +@functools.cache +def build_typescript_sdk(): + pnpm("install", cwd=TYPESCRIPT_BINDINGS_PATH) + pnpm("build", cwd=TYPESCRIPT_BINDINGS_PATH) + def spacetime(*args, **kwargs): return run_cmd(SPACETIME_BIN, *args, cmd_name="spacetime", **kwargs) diff --git a/smoketests/docker.py b/smoketests/docker.py index d45848995fe..d5cdc5e399e 100644 --- a/smoketests/docker.py +++ b/smoketests/docker.py @@ -26,9 +26,13 @@ def restart_docker(): # Ensure all nodes are running. attempts = 0 - while attempts < 5: + while attempts < 10: attempts += 1 - if all(container.is_running(docker, spacetimedb_ping_url) for container in containers): + containers_alive = { + container.name: container.is_running(docker, spacetimedb_ping_url) + for container in containers + } + if all(containers_alive.values()): # sleep a bit more to allow for leader election etc # TODO: make ping endpoint consider all server state time.sleep(2) @@ -36,7 +40,7 @@ def restart_docker(): else: time.sleep(1) - raise Exception("Not all containers are up and running") + raise Exception(f"Not all containers are up and running: {containers_alive!r}") def spacetimedb_ping_url(port: int) -> str: return f"http://127.0.0.1:{port}/v1/ping" diff --git a/smoketests/tests/quickstart.py b/smoketests/tests/quickstart.py index fdcf3b7a4e5..95e06787fd2 100644 --- a/smoketests/tests/quickstart.py +++ b/smoketests/tests/quickstart.py @@ -6,7 +6,7 @@ import xmltodict import smoketests -from .. import Smoketest, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML +from .. import Smoketest, STDB_DIR, run_cmd, TEMPLATE_CARGO_TOML, TYPESCRIPT_BINDINGS_PATH, build_typescript_sdk, pnpm def _write_file(path: Path, content: str): @@ -19,12 +19,13 @@ def _append_to_file(path: Path, content: str): f.write(content) -def _parse_quickstart(doc_path: Path, language: str) -> str: +def _parse_quickstart(doc_path: Path, language: str, module_name: 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) + codeblock_lang = "ts" if language == "typescript" else language + blocks = re.findall(rf"```{codeblock_lang}\n(.*?)\n```", content, re.DOTALL) end = "" if language == "csharp": @@ -42,7 +43,7 @@ def _parse_quickstart(doc_path: Path, language: str) -> str: 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 + return "\n".join(blocks).replace("quickstart-chat", module_name) + end def load_nuget_config(p: Path): if p.exists(): @@ -101,6 +102,8 @@ class BaseQuickstart(Smoketest): MODULE_CODE = "" lang = None + client_lang = None + codeblock_langs = None server_doc = None client_doc = None server_file = None @@ -118,12 +121,16 @@ def project_init(self, path: Path): def sdk_setup(self, path: Path): raise NotImplementedError + @property + def _module_name(self): + return f"quickstart-chat-{self.lang}" + def _publish(self) -> Path: base_path = Path(self.enterClassContext(tempfile.TemporaryDirectory())) server_path = base_path / "server" self.generate_server(server_path) - self.publish_module(f"quickstart-chat-{self.lang}", capture_stderr=True, clear=True) + self.publish_module(self._module_name, capture_stderr=True, clear=True) return base_path / "client" def generate_server(self, server_path: Path): @@ -141,7 +148,7 @@ def generate_server(self, server_path: Path): ) self.project_path = server_path / "spacetimedb" shutil.copy2(STDB_DIR / "rust-toolchain.toml", self.project_path) - _write_file(self.project_path / self.server_file, _parse_quickstart(self.server_doc, self.lang)) + _write_file(self.project_path / self.server_file, _parse_quickstart(self.server_doc, self.lang, self._module_name)) self.server_postprocess(self.project_path) self.spacetime("build", "-d", "-p", self.project_path, capture_stderr=True) @@ -163,13 +170,14 @@ def _test_quickstart(self): run_cmd(*self.build_cmd, cwd=client_path, capture_stderr=True) + client_lang = self.client_lang or self.lang self.spacetime( - "generate", "--lang", self.lang, + "generate", "--lang", client_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) + main = _parse_quickstart(self.client_doc, client_lang, self._module_name) for src, dst in self.replacements.items(): main = main.replace(src, dst) main += "\n" + self.extra_code @@ -322,3 +330,20 @@ def test_quickstart(self): if not smoketests.HAVE_DOTNET: self.skipTest("C# SDK requires .NET to be installed.") self._test_quickstart() + +# We use the Rust client for testing the TypeScript server quickstart because +# the TypeScript client quickstart is a React app, which is difficult to +# smoketest. +class TypeScript(Rust): + lang = "typescript" + client_lang = "rust" + server_doc = STDB_DIR / "docs/docs/06-Server Module Languages/05-typescript-quickstart.md" + server_file = "src/index.ts" + + def server_postprocess(self, server_path: Path): + build_typescript_sdk() + pnpm("install", TYPESCRIPT_BINDINGS_PATH, cwd=server_path) + + def test_quickstart(self): + """Run the TypeScript quickstart guides for server.""" + self._test_quickstart()