From ede3201fa20ac23f421a44a9463eda2bfbbd77bb Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Mon, 13 Apr 2026 16:36:58 -1000 Subject: [PATCH 1/5] Plumb frontend_path into vite.config.js `base` option * also initialize vite.config.js during compile, in case user has set a different runtime frontend path * remove assetsDir workaround (base handles this case) --- .../reflex-base/src/reflex_base/compiler/templates.py | 4 ++-- reflex/app.py | 9 ++++++++- reflex/utils/build.py | 1 + 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index be3e3f6eee4..b4056227987 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -506,7 +506,7 @@ def vite_config_template( """Template for vite.config.js. Args: - base: The base path for the Vite config. + base: The base path for the Vite config (for handling frontend_path config). hmr: Whether to enable hot module replacement. force_full_reload: Whether to force a full reload on changes. experimental_hmr: Whether to enable experimental HMR features. @@ -562,13 +562,13 @@ def vite_config_template( }} export default defineConfig((config) => ({{ + base: "{base}", plugins: [ alwaysUseReactDomServerNode(), reactRouter(), safariCacheBustPlugin(), ].concat({"[fullReload()]" if force_full_reload else "[]"}), build: {{ - assetsDir: "{base}assets".slice(1), sourcemap: {"true" if sourcemap is True else "false" if sourcemap is False else repr(sourcemap)}, rollupOptions: {{ onwarn(warning, warn) {{ diff --git a/reflex/app.py b/reflex/app.py index 389d18f246a..bdbb90bfe40 100644 --- a/reflex/app.py +++ b/reflex/app.py @@ -1220,7 +1220,7 @@ def get_compilation_time() -> str: ) # try to be somewhat accurate - but still not 100% - adhoc_steps_without_executor = 7 + adhoc_steps_without_executor = 8 fixed_pages_within_executor = 4 plugin_count = len(config.plugins) progress.start() @@ -1278,6 +1278,13 @@ def get_compilation_time() -> str: progress.advance(task) + # Reinitialize vite config in case runtime options have changed. + compile_results.append(( + constants.ReactRouter.VITE_CONFIG_FILE, + frontend_skeleton._compile_vite_config(config), + )) + progress.advance(task) + # Track imports found. all_imports = {} diff --git a/reflex/utils/build.py b/reflex/utils/build.py index e4f928434b3..c25bb5660c1 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -241,6 +241,7 @@ def build(): config = get_config() if frontend_path := config.frontend_path.strip("/"): + # Create a subdirectory that matches the configured frontend_path. frontend_path = PosixPath(frontend_path) first_part = frontend_path.parts[0] for child in list((wdir / constants.Dirs.STATIC).iterdir()): From bcdc4b1f042d91cd76867080120e9441a88c98bd Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 14 Apr 2026 09:39:57 -1000 Subject: [PATCH 2/5] Add test_frontend_path and make sure APIs respect it Add `prepend_frontend_path` to `rx.Config` to make it easy to handle the contract in current and future APIs. Use `prepend_frontend_path` in: * rx.get_upload_url * rx.asset * vite config * react router config --- .../reflex-base/src/reflex_base/config.py | 13 + reflex/assets.py | 3 +- reflex/testing.py | 11 +- reflex/utils/exec.py | 2 +- reflex/utils/frontend_skeleton.py | 11 +- .../tests_playwright/test_frontend_path.py | 467 ++++++++++++++++++ tests/units/test_prerequisites.py | 6 +- 7 files changed, 494 insertions(+), 19 deletions(-) create mode 100644 tests/integration/tests_playwright/test_frontend_path.py diff --git a/packages/reflex-base/src/reflex_base/config.py b/packages/reflex-base/src/reflex_base/config.py index ec32a5623b1..a3838af9b22 100644 --- a/packages/reflex-base/src/reflex_base/config.py +++ b/packages/reflex-base/src/reflex_base/config.py @@ -476,6 +476,19 @@ def json(self) -> str: return json.dumps(self, default=serialize) + def prepend_frontend_path(self, path: str) -> str: + """Prepend the frontend path to a given path. + + Args: + path: The path to prepend the frontend path to. + + Returns: + The path with the frontend path prepended if it begins with a slash, otherwise the original path. + """ + if self.frontend_path and path.startswith("/"): + return f"/{self.frontend_path.strip('/')}{path}" + return path + @property def app_module(self) -> ModuleType | None: """Return the app module if `app_module_import` is set. diff --git a/reflex/assets.py b/reflex/assets.py index b2860e84a4b..1b65d2f6a69 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -4,6 +4,7 @@ from pathlib import Path from reflex_base import constants +from reflex_base.config import get_config from reflex_base.environment import EnvironmentVariables @@ -92,7 +93,7 @@ def asset( if not backend_only and not src_file_local.exists(): msg = f"File not found: {src_file_local}" raise FileNotFoundError(msg) - return f"/{path}" + return get_config().prepend_frontend_path(f"/{path}") # Shared asset handling # Determine the file by which the asset is exposed. diff --git a/reflex/testing.py b/reflex/testing.py index c144b22d7c6..9a3ff023049 100644 --- a/reflex/testing.py +++ b/reflex/testing.py @@ -896,18 +896,19 @@ def _run_frontend(self): / reflex.utils.prerequisites.get_web_dir() / reflex.constants.Dirs.STATIC ) - error_page_map = { - 404: web_root / "404.html", - } + config = reflex.config.get_config() with Subdir404TCPServer( ("", 0), SimpleHTTPRequestHandlerCustomErrors, root=web_root, - error_page_map=error_page_map, + error_page_map={ + 404: web_root / config.prepend_frontend_path("/404.html").lstrip("/"), + }, ) as self.frontend_server: + frontend_path = config.frontend_path.strip("/") self.frontend_url = "http://localhost:{1}".format( *self.frontend_server.socket.getsockname() - ) + ) + (f"/{frontend_path}/" if frontend_path else "/") self.frontend_server.serve_forever() def _start_frontend(self): diff --git a/reflex/utils/exec.py b/reflex/utils/exec.py index 26479b96e11..f2cbf387a10 100644 --- a/reflex/utils/exec.py +++ b/reflex/utils/exec.py @@ -280,7 +280,7 @@ def get_frontend_mount(): config = get_config() return Mount( - "/" + config.frontend_path.strip("/"), + config.prepend_frontend_path("/"), app=StaticFiles( directory=prerequisites.get_web_dir() / constants.Dirs.STATIC diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index a730f2630ef..dc60f5ae85b 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -205,12 +205,8 @@ def update_react_router_config(prerender_routes: bool = False): def _update_react_router_config(config: Config, prerender_routes: bool = False): - basename = "/" + (config.frontend_path or "").strip("/") - if not basename.endswith("/"): - basename += "/" - react_router_config = { - "basename": basename, + "basename": config.prepend_frontend_path("/"), "future": { "unstable_optimizeDeps": True, }, @@ -244,11 +240,8 @@ def initialize_package_json(): def _compile_vite_config(config: Config): # base must have exactly one trailing slash - base = "/" - if frontend_path := config.frontend_path.strip("/"): - base += frontend_path + "/" return templates.vite_config_template( - base=base, + base=config.prepend_frontend_path("/"), hmr=environment.VITE_HMR.get(), force_full_reload=environment.VITE_FORCE_FULL_RELOAD.get(), experimental_hmr=environment.VITE_EXPERIMENTAL_HMR.get(), diff --git a/tests/integration/tests_playwright/test_frontend_path.py b/tests/integration/tests_playwright/test_frontend_path.py new file mode 100644 index 00000000000..7ee29b8a15a --- /dev/null +++ b/tests/integration/tests_playwright/test_frontend_path.py @@ -0,0 +1,467 @@ +"""Integration tests for the frontend_path config option. + +Tests that links, redirects, assets, uploaded files, and on_load events all +work correctly when the app is served from a subpath (e.g., /prefix) and also +when served from the root (no frontend_path set). + +Covers dev and prod modes via ``app_harness_env`` parametrisation. +""" + +from __future__ import annotations + +from collections.abc import Generator + +import httpx +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + +# --------------------------------------------------------------------------- +# Test application +# --------------------------------------------------------------------------- + + +def FrontendPathApp(): + """App exercising links, redirects, assets, uploads, and on_load under frontend_path.""" + from pathlib import Path + + import reflex as rx + + class FPState(rx.State): + on_load_events: list[str] = [] + + @rx.event + def on_load_index(self): + self.on_load_events.append("index") + + @rx.event + def on_load_static(self): + self.on_load_events.append("static") + + @rx.event + def on_load_dynamic(self): + page_id = self.page_id # pyright: ignore[reportAttributeAccessIssue] + self.on_load_events.append(f"dynamic-{page_id}") + + @rx.event + def on_load_redirect_target(self): + self.on_load_events.append("redirect-target") + + @rx.event + def redirect_to_index(self): + return rx.redirect("/") + + @rx.event + def redirect_to_static(self): + return rx.redirect("/static-page") + + @rx.event + def redirect_to_dynamic(self): + return rx.redirect("/dynamic/42") + + # Write a test asset into the assets directory. + Path("assets/test_image.png").parent.mkdir(parents=True, exist_ok=True) + # Create a tiny valid 1x1 red PNG. + import struct + import zlib + + def _make_png() -> bytes: + """Create a minimal valid 1x1 red PNG image. + + Returns: + The bytes of the PNG file. + """ + + def _chunk(chunk_type: bytes, data: bytes) -> bytes: + c = chunk_type + data + return ( + struct.pack(">I", len(data)) + + c + + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + ) + + sig = b"\x89PNG\r\n\x1a\n" + ihdr = _chunk(b"IHDR", struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)) + raw = b"\x00\xff\x00\x00" # filter-none + R G B + idat = _chunk(b"IDAT", zlib.compress(raw)) + iend = _chunk(b"IEND", b"") + return sig + ihdr + idat + iend + + Path("assets/test_image.png").write_bytes(_make_png()) + + # Write an external CSS file that references the image via url(). + Path("assets/bg.css").write_text( + ".bg-image { background-image: url(/test_image.png);" + " width: 50px; height: 50px; }" + ) + + # Write a test file to the upload directory so it's served by the backend. + upload_dir = rx.get_upload_dir() + upload_dir.mkdir(parents=True, exist_ok=True) + (upload_dir / "test.txt").write_text("uploaded file content") + (upload_dir / "test.png").write_bytes(_make_png()) + + # ---- Pages ---- + + @rx.page("/", on_load=FPState.on_load_index) + def index(): + return rx.box( + rx.text("index page", id="page-id"), + # Client token for waiting on state hydration. + rx.input( + value=FPState.router.session.client_token, + read_only=True, + id="token", + ), + # Links to app-relative paths. + rx.link("go to static", href="/static-page", id="link-static"), + rx.link("go to dynamic 7", href="/dynamic/7", id="link-dynamic"), + rx.link("go to dynamic 99", href="/dynamic/99", id="link-dynamic-99"), + # Asset image using app-relative path. + rx.el.img(src=rx.asset("test_image.png"), id="asset-img", alt="asset"), + # Uploaded file via get_upload_url. + rx.el.img( + src=rx.get_upload_url("test.png"), + id="upload-img", + alt="uploaded", + ), + rx.link( + "download uploaded file", + href=rx.get_upload_url("test.txt"), + id="upload-link", + ), + # Element styled by external CSS with background-image: url(). + rx.el.div(id="css-bg-image", class_name="bg-image"), + # Buttons that trigger redirects through event handlers. + rx.button( + "redirect to static", + on_click=FPState.redirect_to_static, + id="btn-redir-static", + ), + rx.button( + "redirect to dynamic 42", + on_click=FPState.redirect_to_dynamic, + id="btn-redir-dynamic", + ), + # on_load event log. + rx.box( + rx.foreach(FPState.on_load_events, rx.text), + id="on-load-log", + ), + ) + + @rx.page("/static-page", on_load=FPState.on_load_static) + def static_page(): + return rx.box( + rx.text("static page", id="page-id"), + rx.input( + value=FPState.router.session.client_token, + read_only=True, + id="token", + ), + rx.link("go home", href="/", id="link-home"), + rx.link("go to dynamic 7", href="/dynamic/7", id="link-dynamic"), + rx.box( + rx.foreach(FPState.on_load_events, rx.text), + id="on-load-log", + ), + ) + + @rx.page("/dynamic/[page_id]", on_load=FPState.on_load_dynamic) + def dynamic_page(): + return rx.box( + rx.text(f"dynamic page {rx.State.page_id}", id="page-id"), # pyright: ignore[reportAttributeAccessIssue] + rx.input( + value=FPState.router.session.client_token, + read_only=True, + id="token", + ), + rx.link("go home", href="/", id="link-home"), + rx.link("go to static", href="/static-page", id="link-static"), + rx.box( + rx.foreach(FPState.on_load_events, rx.text), + id="on-load-log", + ), + ) + + # Page whose on_load redirects to a static page. + @rx.page("/bouncer-static", on_load=rx.redirect("/static-page")) + def bouncer_static(): + return rx.text("you should not see this") + + # Page whose on_load redirects to a dynamic page. + @rx.page("/bouncer-dynamic", on_load=rx.redirect("/dynamic/99")) + def bouncer_dynamic(): + return rx.text("you should not see this") + + app = rx.App(stylesheets=["bg.css"]) # noqa: F841 + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture( + scope="module", + params=["", "/prefix"], + ids=["no-prefix", "with-prefix"], +) +def frontend_path(request: pytest.FixtureRequest) -> str: + """Parametrise over no-prefix and /prefix. + + Args: + request: pytest fixture for accessing the current parameter. + + Returns: + The frontend_path value for this test instance. + """ + return request.param + + +@pytest.fixture(scope="module") +def frontend_path_app( + app_harness_env: type[AppHarness], + tmp_path_factory: pytest.TempPathFactory, + frontend_path: str, +) -> Generator[AppHarness, None, None]: + """Start the FrontendPathApp in dev or prod mode, with or without frontend_path. + + Args: + app_harness_env: AppHarness (dev) or AppHarnessProd (prod). + tmp_path_factory: pytest fixture for creating temporary directories. + frontend_path: "" or "/prefix". + + Yields: + Running AppHarness instance. + """ + suffix = frontend_path.strip("/") or "root" + name = f"frontendpath_{suffix}_{app_harness_env.__name__.lower()}" + + with pytest.MonkeyPatch.context() as mp: + mp.setenv("REFLEX_UPLOADED_FILES_DIR", str(tmp_path_factory.mktemp("uploads"))) + if frontend_path: + mp.setenv("REFLEX_FRONTEND_PATH", frontend_path) + else: + mp.delenv("REFLEX_FRONTEND_PATH", raising=False) + + with app_harness_env.create( + root=tmp_path_factory.mktemp(name), + app_name=name, + app_source=FrontendPathApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +def _wait_for_token(page: Page) -> None: + """Wait until the app has hydrated by checking for a non-empty client token. + + Args: + page: Playwright page. + """ + token = page.locator("#token") + expect(token).not_to_have_value("") + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +def test_index_loads(frontend_path_app: AppHarness, page: Page): + """Index page loads at the correct path and on_load fires.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + expect(page.locator("#page-id")).to_have_text("index page") + expect(page.locator("#on-load-log")).to_contain_text("index") + + +def test_link_to_static_page(frontend_path_app: AppHarness, page: Page): + """Client-side link navigates to a static route and on_load fires.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + page.click("#link-static") + expect(page.locator("#page-id")).to_have_text("static page") + expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + expect(page.locator("#on-load-log")).to_contain_text("static") + + +def test_link_to_dynamic_page(frontend_path_app: AppHarness, page: Page): + """Client-side link navigates to a dynamic route and on_load fires.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + page.click("#link-dynamic") + expect(page.locator("#page-id")).to_contain_text("dynamic page") + expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/7") + expect(page.locator("#on-load-log")).to_contain_text("dynamic-7") + + +def test_direct_navigation_static(frontend_path_app: AppHarness, page: Page): + """Direct URL navigation to a static page works (full page load).""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/static-page") + _wait_for_token(page) + expect(page.locator("#page-id")).to_have_text("static page") + expect(page.locator("#on-load-log")).to_contain_text("static") + + +def test_direct_navigation_dynamic(frontend_path_app: AppHarness, page: Page): + """Direct URL navigation to a dynamic page works (full page load).""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/dynamic/42") + _wait_for_token(page) + expect(page.locator("#page-id")).to_contain_text("dynamic page") + expect(page.locator("#on-load-log")).to_contain_text("dynamic-42") + + +def test_redirect_to_static(frontend_path_app: AppHarness, page: Page): + """Event handler redirect to a static route works.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + page.click("#btn-redir-static") + expect(page.locator("#page-id")).to_have_text("static page") + expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + + +def test_redirect_to_dynamic(frontend_path_app: AppHarness, page: Page): + """Event handler redirect to a dynamic route works.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + page.click("#btn-redir-dynamic") + expect(page.locator("#page-id")).to_contain_text("dynamic page") + expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/42") + + +def test_on_load_redirect_static(frontend_path_app: AppHarness, page: Page): + """on_load redirect to a static page works (bouncer pattern).""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/bouncer-static") + _wait_for_token(page) + expect(page.locator("#page-id")).to_have_text("static page") + expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + + +def test_on_load_redirect_dynamic(frontend_path_app: AppHarness, page: Page): + """on_load redirect to a dynamic page works (bouncer pattern).""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/bouncer-dynamic") + _wait_for_token(page) + expect(page.locator("#page-id")).to_contain_text("dynamic page") + expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/99") + + +def test_asset_image_loads(frontend_path_app: AppHarness, page: Page): + """An image from the assets directory loads correctly.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + img = page.locator("#asset-img") + expect(img).to_be_visible() + # Verify the image actually loaded (naturalWidth > 0). + page.wait_for_function("document.querySelector('#asset-img').naturalWidth > 0") + + +def test_css_background_image_loads(frontend_path_app: AppHarness, page: Page): + """An external CSS file referencing an image via url() loads correctly.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + el = page.locator("#css-bg-image") + expect(el).to_be_visible() + # Verify the background-image was applied (not "none"). + expect(el).not_to_have_css("background-image", "none") + + +def test_uploaded_file_image_loads(frontend_path_app: AppHarness, page: Page): + """An image served from the upload directory loads correctly.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + img = page.locator("#upload-img") + expect(img).to_be_visible() + # Wait for the image to be fully loaded. + page.wait_for_function("document.querySelector('#upload-img').naturalWidth > 0") + + +def test_uploaded_file_download(frontend_path_app: AppHarness, page: Page): + """A file in the upload directory can be downloaded via get_upload_url link.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + + # Get the href from the download link and fetch it directly. + link = page.locator("#upload-link") + expect(link).to_be_visible() + href = link.get_attribute("href") + assert href is not None + + # The href from get_upload_url is an absolute URL pointing at the backend. + resp = httpx.get(href, follow_redirects=True) + assert resp.status_code == 200 + assert resp.text == "uploaded file content" + + +@pytest.mark.ignore_console_error +def test_404_page(frontend_path_app: AppHarness, page: Page): + """Navigating to a non-existent page shows the 404 page.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(f"{base.rstrip('/')}/this-page-does-not-exist") + expect(page.get_by_text("404")).to_be_visible(timeout=10000) + + +def test_navigate_back_and_forth(frontend_path_app: AppHarness, page: Page): + """Navigate between pages and verify on_load fires each time.""" + base = frontend_path_app.frontend_url + assert base is not None + page.goto(base) + _wait_for_token(page) + expect(page.locator("#page-id")).to_have_text("index page") + + # index -> static + page.click("#link-static") + expect(page.locator("#page-id")).to_have_text("static page") + expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + + # static -> dynamic/7 + page.click("#link-dynamic") + expect(page.locator("#page-id")).to_contain_text("dynamic page") + expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/7") + + # dynamic/7 -> index (via link-home) + page.click("#link-home") + expect(page.locator("#page-id")).to_have_text("index page") + expect(page).to_have_url(base) + + # Verify on_load fired for each navigation. + log = page.locator("#on-load-log") + expect(log).to_contain_text("index") + expect(log).to_contain_text("static") + expect(log).to_contain_text("dynamic-7") diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 9b908002850..c9cb76c3c0b 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -67,21 +67,21 @@ def test_update_react_router_config(config, export, expected_output): app_name="test", frontend_path="", ), - 'assetsDir: "/assets".slice(1),', + 'base: "/",', ), ( Config( app_name="test", frontend_path="/test", ), - 'assetsDir: "/test/assets".slice(1),', + 'base: "/test/",', ), ( Config( app_name="test", frontend_path="/test/", ), - 'assetsDir: "/test/assets".slice(1),', + 'base: "/test/",', ), ], ) From caa4fd4cf270331a792caac0655102f5613adf76 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 14 Apr 2026 10:06:11 -1000 Subject: [PATCH 3/5] consolidate boilerplate logic in test_frontend_path --- .../tests_playwright/test_frontend_path.py | 115 ++++++------------ 1 file changed, 40 insertions(+), 75 deletions(-) diff --git a/tests/integration/tests_playwright/test_frontend_path.py b/tests/integration/tests_playwright/test_frontend_path.py index 7ee29b8a15a..749fbcf2086 100644 --- a/tests/integration/tests_playwright/test_frontend_path.py +++ b/tests/integration/tests_playwright/test_frontend_path.py @@ -255,14 +255,27 @@ def frontend_path_app( yield harness -def _wait_for_token(page: Page) -> None: - """Wait until the app has hydrated by checking for a non-empty client token. +def _navigate(harness: AppHarness, page: Page, path: str = "/") -> str: + """Navigate to ``path`` under the harness frontend and wait for hydration. + + Prepends ``frontend_url`` to *path*, navigates the Playwright *page*, and + waits until the client token is present (indicating state hydration). Args: + harness: The running AppHarness (provides ``frontend_url``). page: Playwright page. + path: App-relative path to navigate to (e.g. ``/static-page``). + + Returns: + The frontend base URL (``frontend_url`` with trailing slash stripped) + for use in subsequent URL assertions. """ - token = page.locator("#token") - expect(token).not_to_have_value("") + base = harness.frontend_url + assert base is not None + base = base.rstrip("/") + page.goto(f"{base}{path}") + expect(page.locator("#token")).not_to_have_value("") + return base # --------------------------------------------------------------------------- @@ -272,163 +285,119 @@ def _wait_for_token(page: Page) -> None: def test_index_loads(frontend_path_app: AppHarness, page: Page): """Index page loads at the correct path and on_load fires.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + _navigate(frontend_path_app, page) expect(page.locator("#page-id")).to_have_text("index page") expect(page.locator("#on-load-log")).to_contain_text("index") def test_link_to_static_page(frontend_path_app: AppHarness, page: Page): """Client-side link navigates to a static route and on_load fires.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + base = _navigate(frontend_path_app, page) page.click("#link-static") expect(page.locator("#page-id")).to_have_text("static page") - expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + expect(page).to_have_url(f"{base}/static-page") expect(page.locator("#on-load-log")).to_contain_text("static") def test_link_to_dynamic_page(frontend_path_app: AppHarness, page: Page): """Client-side link navigates to a dynamic route and on_load fires.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + base = _navigate(frontend_path_app, page) page.click("#link-dynamic") expect(page.locator("#page-id")).to_contain_text("dynamic page") - expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/7") + expect(page).to_have_url(f"{base}/dynamic/7") expect(page.locator("#on-load-log")).to_contain_text("dynamic-7") def test_direct_navigation_static(frontend_path_app: AppHarness, page: Page): """Direct URL navigation to a static page works (full page load).""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(f"{base.rstrip('/')}/static-page") - _wait_for_token(page) + _navigate(frontend_path_app, page, "/static-page") expect(page.locator("#page-id")).to_have_text("static page") expect(page.locator("#on-load-log")).to_contain_text("static") def test_direct_navigation_dynamic(frontend_path_app: AppHarness, page: Page): """Direct URL navigation to a dynamic page works (full page load).""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(f"{base.rstrip('/')}/dynamic/42") - _wait_for_token(page) + _navigate(frontend_path_app, page, "/dynamic/42") expect(page.locator("#page-id")).to_contain_text("dynamic page") expect(page.locator("#on-load-log")).to_contain_text("dynamic-42") def test_redirect_to_static(frontend_path_app: AppHarness, page: Page): """Event handler redirect to a static route works.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + base = _navigate(frontend_path_app, page) page.click("#btn-redir-static") expect(page.locator("#page-id")).to_have_text("static page") - expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + expect(page).to_have_url(f"{base}/static-page") def test_redirect_to_dynamic(frontend_path_app: AppHarness, page: Page): """Event handler redirect to a dynamic route works.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + base = _navigate(frontend_path_app, page) page.click("#btn-redir-dynamic") expect(page.locator("#page-id")).to_contain_text("dynamic page") - expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/42") + expect(page).to_have_url(f"{base}/dynamic/42") def test_on_load_redirect_static(frontend_path_app: AppHarness, page: Page): """on_load redirect to a static page works (bouncer pattern).""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(f"{base.rstrip('/')}/bouncer-static") - _wait_for_token(page) + base = _navigate(frontend_path_app, page, "/bouncer-static") expect(page.locator("#page-id")).to_have_text("static page") - expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + expect(page).to_have_url(f"{base}/static-page") def test_on_load_redirect_dynamic(frontend_path_app: AppHarness, page: Page): """on_load redirect to a dynamic page works (bouncer pattern).""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(f"{base.rstrip('/')}/bouncer-dynamic") - _wait_for_token(page) + base = _navigate(frontend_path_app, page, "/bouncer-dynamic") expect(page.locator("#page-id")).to_contain_text("dynamic page") - expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/99") + expect(page).to_have_url(f"{base}/dynamic/99") def test_asset_image_loads(frontend_path_app: AppHarness, page: Page): """An image from the assets directory loads correctly.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + _navigate(frontend_path_app, page) img = page.locator("#asset-img") expect(img).to_be_visible() - # Verify the image actually loaded (naturalWidth > 0). page.wait_for_function("document.querySelector('#asset-img').naturalWidth > 0") def test_css_background_image_loads(frontend_path_app: AppHarness, page: Page): """An external CSS file referencing an image via url() loads correctly.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + _navigate(frontend_path_app, page) el = page.locator("#css-bg-image") expect(el).to_be_visible() - # Verify the background-image was applied (not "none"). expect(el).not_to_have_css("background-image", "none") def test_uploaded_file_image_loads(frontend_path_app: AppHarness, page: Page): """An image served from the upload directory loads correctly.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + _navigate(frontend_path_app, page) img = page.locator("#upload-img") expect(img).to_be_visible() - # Wait for the image to be fully loaded. page.wait_for_function("document.querySelector('#upload-img').naturalWidth > 0") def test_uploaded_file_download(frontend_path_app: AppHarness, page: Page): """A file in the upload directory can be downloaded via get_upload_url link.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + _navigate(frontend_path_app, page) - # Get the href from the download link and fetch it directly. link = page.locator("#upload-link") expect(link).to_be_visible() href = link.get_attribute("href") assert href is not None - # The href from get_upload_url is an absolute URL pointing at the backend. resp = httpx.get(href, follow_redirects=True) assert resp.status_code == 200 assert resp.text == "uploaded file content" -@pytest.mark.ignore_console_error +# @pytest.mark.ignore_console_error def test_404_page(frontend_path_app: AppHarness, page: Page): """Navigating to a non-existent page shows the 404 page.""" base = frontend_path_app.frontend_url @@ -439,26 +408,22 @@ def test_404_page(frontend_path_app: AppHarness, page: Page): def test_navigate_back_and_forth(frontend_path_app: AppHarness, page: Page): """Navigate between pages and verify on_load fires each time.""" - base = frontend_path_app.frontend_url - assert base is not None - page.goto(base) - _wait_for_token(page) + base = _navigate(frontend_path_app, page) expect(page.locator("#page-id")).to_have_text("index page") # index -> static page.click("#link-static") expect(page.locator("#page-id")).to_have_text("static page") - expect(page).to_have_url(f"{base.rstrip('/')}/static-page") + expect(page).to_have_url(f"{base}/static-page") # static -> dynamic/7 page.click("#link-dynamic") expect(page.locator("#page-id")).to_contain_text("dynamic page") - expect(page).to_have_url(f"{base.rstrip('/')}/dynamic/7") + expect(page).to_have_url(f"{base}/dynamic/7") # dynamic/7 -> index (via link-home) page.click("#link-home") expect(page.locator("#page-id")).to_have_text("index page") - expect(page).to_have_url(base) # Verify on_load fired for each navigation. log = page.locator("#on-load-log") From 1b9c8cb07dad22c749f5104d97b41abdb4f7c364 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 14 Apr 2026 18:09:27 -1000 Subject: [PATCH 4/5] Always run playwright tests in separate CI job playwright uses a session scope fixture which creates an event loop for the session, so subsequent tests using pytest_asyncio fixture cannot start their own loop and fail --- .github/workflows/integration_app_harness.yml | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/.github/workflows/integration_app_harness.yml b/.github/workflows/integration_app_harness.yml index a567141fa7f..8cf57f697d8 100644 --- a/.github/workflows/integration_app_harness.yml +++ b/.github/workflows/integration_app_harness.yml @@ -53,10 +53,46 @@ jobs: python-version: ${{ matrix.python-version }} run-uv-sync: true + - name: Run app harness 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 --splits 2 --group ${{matrix.split_index}} + + # Playwright tests run in a separate job because the pytest-playwright plugin + # keeps an asyncio event loop running on the main thread for the entire + # session, which is incompatible with pytest-asyncio tests. + integration-app-harness-playwright: + timeout-minutes: 30 + strategy: + matrix: + state_manager: ["redis", "memory"] + python-version: ["3.11", "3.12", "3.13", "3.14"] + fail-fast: false + runs-on: ubuntu-22.04 + services: + redis: + image: ${{ matrix.state_manager == 'redis' && 'redis' || '' }} + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + steps: + - uses: actions/checkout@v4 + with: + fetch-tags: true + fetch-depth: 0 + - uses: ./.github/actions/setup_build_env + with: + python-version: ${{ matrix.python-version }} + run-uv-sync: true + - name: Install playwright run: uv run playwright install chromium --only-shell - - name: Run app harness tests + - name: Run playwright tests env: REFLEX_REDIS_URL: ${{ matrix.state_manager == 'redis' && 'redis://localhost:6379' || '' }} - run: uv run pytest tests/integration --reruns 3 -v --maxfail=5 --splits 2 --group ${{matrix.split_index}} + run: uv run pytest tests/integration/tests_playwright --reruns 3 -v --maxfail=5 From 12cfd39c59cb0799c13d2786d3a5a177e33b7ccc Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Tue, 14 Apr 2026 18:25:20 -1000 Subject: [PATCH 5/5] fix frontend path for shared assets include updated test case --- reflex/assets.py | 2 +- .../tests_playwright/test_frontend_path.py | 22 ++++++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/reflex/assets.py b/reflex/assets.py index 1b65d2f6a69..8dd29fb5e5f 100644 --- a/reflex/assets.py +++ b/reflex/assets.py @@ -129,4 +129,4 @@ def asset( dst_file.unlink() dst_file.symlink_to(src_file_shared) - return f"/{external}/{subfolder}/{path}" + return get_config().prepend_frontend_path(f"/{external}/{subfolder}/{path}") diff --git a/tests/integration/tests_playwright/test_frontend_path.py b/tests/integration/tests_playwright/test_frontend_path.py index 749fbcf2086..b3f38d92898 100644 --- a/tests/integration/tests_playwright/test_frontend_path.py +++ b/tests/integration/tests_playwright/test_frontend_path.py @@ -96,6 +96,9 @@ def _chunk(chunk_type: bytes, data: bytes) -> bytes: " width: 50px; height: 50px; }" ) + # Write a shared asset next to the app module so rx.asset(shared=True) can find it. + (Path(__file__).parent / "shared_image.png").write_bytes(_make_png()) + # Write a test file to the upload directory so it's served by the backend. upload_dir = rx.get_upload_dir() upload_dir.mkdir(parents=True, exist_ok=True) @@ -118,8 +121,14 @@ def index(): rx.link("go to static", href="/static-page", id="link-static"), rx.link("go to dynamic 7", href="/dynamic/7", id="link-dynamic"), rx.link("go to dynamic 99", href="/dynamic/99", id="link-dynamic-99"), - # Asset image using app-relative path. + # Asset image using app-relative path (local asset). rx.el.img(src=rx.asset("test_image.png"), id="asset-img", alt="asset"), + # Shared asset image (library-style asset next to the module file). + rx.el.img( + src=rx.asset("shared_image.png", shared=True), + id="shared-asset-img", + alt="shared asset", + ), # Uploaded file via get_upload_url. rx.el.img( src=rx.get_upload_url("test.png"), @@ -365,6 +374,17 @@ def test_asset_image_loads(frontend_path_app: AppHarness, page: Page): page.wait_for_function("document.querySelector('#asset-img').naturalWidth > 0") +def test_shared_asset_image_loads(frontend_path_app: AppHarness, page: Page): + """A shared (library-style) asset image loads correctly.""" + _navigate(frontend_path_app, page) + + img = page.locator("#shared-asset-img") + expect(img).to_be_visible() + page.wait_for_function( + "document.querySelector('#shared-asset-img').naturalWidth > 0" + ) + + def test_css_background_image_loads(frontend_path_app: AppHarness, page: Page): """An external CSS file referencing an image via url() loads correctly.""" _navigate(frontend_path_app, page)