Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions .github/workflows/integration_tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -364,6 +404,7 @@ jobs:
reflex-docs-macos,
app-harness,
app-harness-playwright,
bun-only-startup,
]
if: always()
runs-on: ubuntu-latest
Expand Down
143 changes: 135 additions & 8 deletions tests/integration/test_nodeless_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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)
Expand Down Expand Up @@ -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:
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
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)
Comment thread
somiljain2006 marked this conversation as resolved.
Resolved
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
Comment thread
somiljain2006 marked this conversation as resolved.
Resolved

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())
Loading