diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml index 63940ebc52c..1e8185c89d4 100644 --- a/.github/workflows/integration_tests.yml +++ b/.github/workflows/integration_tests.yml @@ -311,7 +311,7 @@ jobs: - name: Run non-browser integration tests env: REFLEX_REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }} - run: uv run pytest tests/integration --ignore=tests/integration/tests_playwright --reruns 3 -v --maxfail=5 + run: uv run pytest tests/integration --ignore=tests/integration/tests_playwright --reruns 3 -rs -v --maxfail=5 app-harness-playwright: needs: changes @@ -349,7 +349,47 @@ jobs: - name: Run Playwright integration tests env: REFLEX_REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }} - run: uv run pytest tests/integration/tests_playwright --reruns 3 -v --maxfail=5 --splits 2 --group ${{ matrix.split_index }} + run: uv run pytest tests/integration/tests_playwright --reruns 3 -rs -v --maxfail=5 --splits 2 --group ${{ matrix.split_index }} + bun-only-startup: + needs: changes + if: needs.changes.outputs.run == 'true' + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-tags: true + fetch-depth: 0 + persist-credentials: false + - uses: ./.github/actions/setup_build_env + with: + python-version: ${{ matrix.python-version }} + run-uv-sync: true + - name: Install Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: "1.4.4" + - name: Install outdated Node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20 + - name: Report runtime versions + run: | + python --version + bun --version + node --version || true + uname -a + - name: Run Nodeless and Outdated-Node Integration Tests + env: + REQUIRE_BUN_NODELESS_TEST: "true" + run: | + uv run pytest tests/integration/test_nodeless_frontend.py -rs -v --junitxml=test-results.xml + uv run python -c "import xml.etree.ElementTree as ET; root = ET.parse('test-results.xml').getroot(); skipped = root.findall('.//testcase/skipped'); assert not skipped, f'{len(skipped)} test(s) were skipped'" # The one status check the branch rules require from this workflow. Naming the # jobs above directly does not survive: check names follow the matrix and the @@ -364,6 +404,7 @@ jobs: reflex-docs-macos, app-harness, app-harness-playwright, + bun-only-startup, ] if: always() runs-on: ubuntu-latest diff --git a/tests/integration/test_nodeless_frontend.py b/tests/integration/test_nodeless_frontend.py index dd36e487064..6c519fe9baa 100644 --- a/tests/integration/test_nodeless_frontend.py +++ b/tests/integration/test_nodeless_frontend.py @@ -3,9 +3,12 @@ import os import shutil from collections.abc import Generator +from html.parser import HTMLParser import httpx import pytest +from packaging import version +from reflex_base import constants import reflex.testing as reflex_testing from reflex.testing import AppHarness @@ -37,7 +40,7 @@ def _path_without_node() -> str: ) -@pytest.fixture(scope="module") +@pytest.fixture def nodeless_app(tmp_path_factory) -> Generator[AppHarness, None, None]: """Start NodelessApp with `node` unavailable, as on a bun-only install. @@ -48,11 +51,24 @@ def nodeless_app(tmp_path_factory) -> Generator[AppHarness, None, None]: The running AppHarness. """ with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.delenv("REFLEX_USE_NPM", raising=False) monkeypatch.setenv("PATH", _path_without_node()) - if shutil.which("node") is not None: - pytest.skip("node is still resolvable after filtering it off PATH") - if path_ops.get_bun_path() is None: - pytest.skip("bun is unavailable once node is off PATH") + + is_dedicated_ci = os.environ.get("REQUIRE_BUN_NODELESS_TEST") == "true" + + if is_dedicated_ci: + assert shutil.which("node") is None, ( + "Node is still resolvable after filtering PATH" + ) + assert path_ops.get_bun_path() is not None, ( + "Bun must be available for nodeless tests in CI" + ) + else: + if shutil.which("node") is not None: + pytest.skip("node is still resolvable after filtering it off PATH") + if path_ops.get_bun_path() is None: + pytest.skip("bun is unavailable once node is off PATH") + # The node probe is cached per process, so an earlier test may have # found node; it is off PATH now. monkeypatch.setattr(js_runtimes, "get_node_version", lambda: None) @@ -92,10 +108,121 @@ def test_nodeless_frontend_serves(nodeless_app: AppHarness): assert nodeless_app.frontend_process is not None args = nodeless_app.frontend_process.args assert isinstance(args, list) + bun_path = path_ops.get_bun_path() + assert bun_path is not None + assert args[0] == str(bun_path) + assert "--bun" in args assert _DEV_CONDITION_FLAG in args assert nodeless_app.frontend_url is not None response = httpx.get(nodeless_app.frontend_url, timeout=60) assert response.status_code == 200 - # The page is hydrated client-side, so assert on the react-router document - # the dev server rendered rather than on the app's own text. - assert "__reactRouterContext" in response.text + parser = VisibleTextParser() + parser.feed(response.text) + assert parser.get_visible_text() == "" + assert response.text.count("window.__reactRouterContext = ") == 1 + + +@pytest.fixture +def outdated_node_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start NodelessApp with an actual outdated Node version, forcing fallback to Bun. + + Args: + tmp_path_factory: The pytest temporary path fixture. + + Yields: + The running AppHarness until the test completes. + """ + node_path = path_ops.get_node_path() + bun_path = path_ops.get_bun_path() + + is_dedicated_ci = os.environ.get("REQUIRE_BUN_NODELESS_TEST") == "true" + + if node_path is None: + if is_dedicated_ci: + pytest.fail("Node must be installed") + pytest.skip("Node is not installed") + + if bun_path is None: + if is_dedicated_ci: + pytest.fail("Bun must be installed") + pytest.skip("Bun is not installed") + + node_version = js_runtimes._get_version_of_executable(node_path) + + if node_version is None: + if is_dedicated_ci: + pytest.fail("Could not detect Node version") + pytest.skip("Could not detect Node version") + + if node_version >= version.parse(constants.Node.MIN_VERSION): + if is_dedicated_ci: + pytest.fail(f"Expected outdated Node, found {node_version}") + pytest.skip(f"Requires outdated Node, found supported version {node_version}") + + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.delenv("REFLEX_USE_NPM", raising=False) + monkeypatch.setattr(reflex_testing, "_with_development_condition", dict) + + harness = AppHarness.create( + root=tmp_path_factory.mktemp("outdated_node_app"), app_source=NodelessApp + ) + try: + harness.start() + yield harness + finally: + harness.stop() + + +def test_outdated_node_frontend_serves(outdated_node_app: AppHarness): + """The dev server starts and serves using bun when node is actually outdated.""" + assert outdated_node_app.frontend_process is not None + args = outdated_node_app.frontend_process.args + assert isinstance(args, list) + + bun_path = path_ops.get_bun_path() + assert bun_path is not None + assert args[0] == str(bun_path) + assert "--bun" in args + assert _DEV_CONDITION_FLAG in args + + assert outdated_node_app.frontend_url is not None + response = httpx.get(outdated_node_app.frontend_url, timeout=60) + assert response.status_code == 200 + parser = VisibleTextParser() + parser.feed(response.text) + assert parser.get_visible_text() == "" + assert response.text.count("window.__reactRouterContext = ") == 1 + + +class VisibleTextParser(HTMLParser): + """Extract visible text from the HTML body.""" + + def __init__(self): + """Initialize the parser state.""" + super().__init__() + self.in_body = False + self.ignored_depth = 0 + self.text = [] + + def handle_starttag(self, tag, attrs): + """Track body and ignored-element boundaries.""" + if tag == "body": + self.in_body = True + elif self.in_body and tag in {"script", "style"}: + self.ignored_depth += 1 + + def handle_endtag(self, tag): + """Track the end of body and ignored elements.""" + if tag == "body": + self.in_body = False + elif self.in_body and tag in {"script", "style"} and self.ignored_depth: + self.ignored_depth -= 1 + + def handle_data(self, data): + """Collect visible body text.""" + if self.in_body and not self.ignored_depth: + self.text.append(data) + + def get_visible_text(self): + """Return normalized visible body text.""" + return " ".join(" ".join(self.text).split())