diff --git a/docs/hosting/self-hosting.md b/docs/hosting/self-hosting.md index 384a0aac31c..8711fe511ca 100644 --- a/docs/hosting/self-hosting.md +++ b/docs/hosting/self-hosting.md @@ -112,6 +112,53 @@ this, use the `--no-zip` parameter. This provides the frontend in the `.web/build/client/` directory and the backend can be found in the root directory of the project. +## Reusing a Production Frontend Build + +For repeated deployments with deterministic frontend builds, you can opt in to a +local build cache: + +```bash +REFLEX_FRONTEND_BUILD_CACHE=true reflex deploy +``` + +The same option works with `reflex export` and production-mode `reflex run`. +It is disabled by default. On a cache hit, Reflex restores the pristine JavaScript +build, then runs post-build plugins, fallback generation, compression, and frontend +path processing again. Python compilation and the normal dependency checks still run. + +On macOS, Linux, and Windows, production exports sharing `.web` wait for one +another from compilation through ZIP creation. Production and preview startup +also hold this lock while compiling and building, even when caching is disabled, +and release it before serving. The lock file `.web/.reflex-build.lock` remains in +place between commands; do not remove it while a command is running. +Initialization, development hot reload, and unrelated tools writing to `.web` are +outside this lock, so avoid running them during an export. + +Enable this option only when build output is determined by the tracked local inputs. +Prerendering, Vite plugins, and custom export scripts can read remote data, the clock, +or files outside `.web`; changes to those inputs require a fresh build. Run with +`REFLEX_FRONTEND_BUILD_CACHE=false` to force one and discard the previous snapshot, +or remove `.web/reflex.build-cache`. Re-enabling the option then populates a new cache. + +The cache checks generated frontend source, assets and configuration within `.web`, +the build environment, runtime identity, and installed dependency file metadata. +It also verifies snapshot file contents before restoring them. Use it on a local +macOS or Linux filesystem that reports file modification and change timestamps +reliably; the cache is bypassed on Windows and when links lead outside tracked inputs. + +Generated build output, `.react-router`, the build lock, the dependency-install cache marker, and +the top-level `node_modules/.vite`, `.vite-temp`, and `.cache` directories are +excluded from the input fingerprint. The private `last_reflex_run_datetime`, +`last_version_check_datetime`, and `last_version_check_attempt_datetime` fields in +`reflex.json`, including their per-package timestamp variants, are also excluded. Custom code +that uses these excluded values or files to determine build output should keep the +cache disabled. Cache hits can retain the earlier private timestamps embedded in +bundles; the client framework uses the separately checked Reflex version value. + +Cache misses perform a normal build and have extra fingerprinting and snapshot-copy +work, so this option is most useful when the same frontend is deployed repeatedly. +Deleting `.web` also deletes its cached build. + The export also writes a pre-compressed `.gz` copy of the compressible text assets (JS, CSS, HTML, JSON, SVG, and similar), so configure the static host to serve those directly where it supports it. Set the diff --git a/news/+cache-production-frontend.performance.md b/news/+cache-production-frontend.performance.md new file mode 100644 index 00000000000..adb89a5a8af --- /dev/null +++ b/news/+cache-production-frontend.performance.md @@ -0,0 +1 @@ +Add an opt-in local production frontend build cache with `REFLEX_FRONTEND_BUILD_CACHE=true` for deterministic repeat builds on macOS and Linux. Production exports sharing a frontend directory are serialized; post-build hooks and compression still run, and setting the variable to `false` forces a fresh build and discards cached output. diff --git a/news/+faster-deploy-archives.performance.md b/news/+faster-deploy-archives.performance.md new file mode 100644 index 00000000000..5b849a20ef0 --- /dev/null +++ b/news/+faster-deploy-archives.performance.md @@ -0,0 +1 @@ +Speed up deployment archive creation by reducing filesystem checks for excluded files and avoiding recompression of precompressed frontend assets. diff --git a/news/+preserve-formatted-package-cache.performance.md b/news/+preserve-formatted-package-cache.performance.md new file mode 100644 index 00000000000..f105c546c0b --- /dev/null +++ b/news/+preserve-formatted-package-cache.performance.md @@ -0,0 +1 @@ +Avoid reinstalling frontend dependencies solely because the package manager reformatted `package.json`. Actual changes to dependency versions, scripts, and other manifest values still invalidate the install cache. diff --git a/news/+reuse-installed-framework-pins.performance.md b/news/+reuse-installed-framework-pins.performance.md new file mode 100644 index 00000000000..02ea9964237 --- /dev/null +++ b/news/+reuse-installed-framework-pins.performance.md @@ -0,0 +1 @@ +Speed up frontend setup during deploys by reusing matching framework dependencies after installing from a persisted lockfile. Repair npm development dependency placement and keep build tools installed when `NODE_ENV=production`. diff --git a/news/+skip-cached-version-check.performance.md b/news/+skip-cached-version-check.performance.md new file mode 100644 index 00000000000..9355a3cf44c --- /dev/null +++ b/news/+skip-cached-version-check.performance.md @@ -0,0 +1 @@ +Handle timezone-aware version-check timestamps while preserving per-package caching and failure cooldowns. Failed HTTP responses are not recorded as successful checks. diff --git a/packages/reflex-base/news/+frontend-build-cache.feature.md b/packages/reflex-base/news/+frontend-build-cache.feature.md new file mode 100644 index 00000000000..161df210e2e --- /dev/null +++ b/packages/reflex-base/news/+frontend-build-cache.feature.md @@ -0,0 +1 @@ +Add the `REFLEX_FRONTEND_BUILD_CACHE` environment option for opting into local reuse of deterministic production frontend builds. It is disabled by default. diff --git a/packages/reflex-base/src/reflex_base/environment.py b/packages/reflex-base/src/reflex_base/environment.py index c937d0e9b34..171c1f7bf8f 100644 --- a/packages/reflex-base/src/reflex_base/environment.py +++ b/packages/reflex-base/src/reflex_base/environment.py @@ -649,6 +649,10 @@ class EnvironmentVariables: # Whether to use npm over bun to install and run the frontend. REFLEX_USE_NPM: EnvVar[bool] = env_var(False) + # Opt in to local reuse of deterministic production frontend builds. + # External/time-dependent build inputs require a forced fresh build. + REFLEX_FRONTEND_BUILD_CACHE: EnvVar[bool] = env_var(False) + # The npm registry to use. NPM_CONFIG_REGISTRY: EnvVar[str | None] = env_var(None) diff --git a/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md b/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md new file mode 100644 index 00000000000..21f5a0aa644 --- /dev/null +++ b/packages/reflex-hosting-cli/news/+skip-unused-deploy-provider-check.performance.md @@ -0,0 +1 @@ +Speed up non-interactive deploys by skipping the provider availability request used only for the interactive provider prompt. diff --git a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py index 1e1ac96eaf6..a83ae4d67f9 100644 --- a/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py +++ b/packages/reflex-hosting-cli/src/reflex_cli/v2/cli.py @@ -263,8 +263,8 @@ def _resolve_deploy_provider( # Explicit --provider always wins; validated by the caller already. target = hosting.normalize_provider(provider_arg) else: - gcp_status = hosting.gcp_deploy_available(client) - if not gcp_status or not interactive: + gcp_status = hosting.gcp_deploy_available(client) if interactive else None + if not gcp_status: # No GCP connected, or non-interactive with no explicit choice: keep # whatever the app already targets. target = current diff --git a/reflex/reflex.py b/reflex/reflex.py index a72cbec40b3..d037e1b4e54 100644 --- a/reflex/reflex.py +++ b/reflex/reflex.py @@ -4,6 +4,7 @@ import logging from collections.abc import Callable +from contextlib import nullcontext from importlib import import_module from importlib.util import find_spec from pathlib import Path @@ -463,21 +464,33 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): """ import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_cache, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - # Mount the compiled frontend into the dev backend so no Vite server is needed. - environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( - running_mode.has_frontend() and running_mode.has_backend() - ) + # Mount the compiled frontend into the dev backend so no Vite server is needed. + environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP.set( + running_mode.has_frontend() and running_mode.has_backend() + ) - if running_mode.has_frontend(): - # Compile the app and produce the initial frontend build. - _compile_app() - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Compile the app and produce the initial frontend build. + _compile_app() + build.setup_frontend_prod(Path.cwd()) # Post a telemetry event. telemetry.send("run-preview") @@ -502,16 +515,28 @@ def _run_preview(running_mode: constants.RunningMode, port: int, host: str): def _run_prod(running_mode: constants.RunningMode, port: int, host: str): import atexit - from reflex.utils import build, exec, processes, telemetry + from reflex.utils import ( + build, + build_cache, + exec, + prerequisites, + processes, + telemetry, + ) config = get_config() - config._set_persistent(frontend_port=port, backend_port=port) + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if running_mode.has_frontend() + else nullcontext() + ): + config._set_persistent(frontend_port=port, backend_port=port) - if running_mode.has_frontend(): - # Get the app module. - _compile_app(avoid_dirty_check=False) - build.setup_frontend_prod(Path.cwd()) + if running_mode.has_frontend(): + # Get the app module. + _compile_app(avoid_dirty_check=False) + build.setup_frontend_prod(Path.cwd()) _skip_compile() diff --git a/reflex/utils/build.py b/reflex/utils/build.py index 3673408051c..a47d7c2ff7c 100644 --- a/reflex/utils/build.py +++ b/reflex/utils/build.py @@ -10,7 +10,14 @@ from reflex_base import constants from reflex_base.config import get_config -from reflex.utils import console, js_runtimes, path_ops, prerequisites, processes +from reflex.utils import ( + build_cache, + console, + js_runtimes, + path_ops, + prerequisites, + processes, +) from reflex.utils.exec import frontend_env, is_in_app_harness logger = logging.getLogger(__name__) @@ -34,6 +41,41 @@ def set_env_json(): ) +def _zip_compress_type(component_name: constants.ComponentName, file: Path) -> int: + """Select compression suitable for one archive entry. + + Args: + component_name: The archive being created. + file: The source file being archived. + + Returns: + The ZIP compression type for the file. + """ + if component_name == constants.ComponentName.FRONTEND and file.suffix in { + ".gz", + ".br", + ".zst", + }: + return zipfile.ZIP_STORED + return zipfile.ZIP_DEFLATED + + +def _is_excluded_archive_path( + path: Path, excluded_file_ids: set[tuple[int, int]] +) -> bool: + """Check whether an archive path has an excluded file identity. + + Args: + path: The path being considered for the archive. + excluded_file_ids: Device and inode pairs that must be excluded. + + Returns: + Whether the path refers to an excluded file or directory. + """ + stat = path.stat() + return (stat.st_dev, stat.st_ino) in excluded_file_ids + + def _zip( *, component_name: constants.ComponentName, @@ -62,6 +104,12 @@ def _zip( root_directory = Path(root_directory).resolve() directory_names_to_exclude = directory_names_to_exclude or set() files_to_exclude = files_to_exclude or set() + excluded_file_ids = set() + for excluded_file in files_to_exclude: + if excluded_file.exists(): + stat = excluded_file.stat() + excluded_file_ids.add((stat.st_dev, stat.st_ino)) + files_to_zip: list[Path] = [] # Traverse the root directory in a top-down manner. In this traversal order, # we can modify the dirs list in-place to remove directories we don't want to include. @@ -74,10 +122,11 @@ def _zip( subdirectory_name for subdirectory_name in subdirectories_names if subdirectory_name not in directory_names_to_exclude - and not any( - (directory_path / subdirectory_name).samefile(exclude) - for exclude in files_to_exclude - if exclude.exists() + and ( + not excluded_file_ids + or not _is_excluded_archive_path( + directory_path / subdirectory_name, excluded_file_ids + ) ) and not subdirectory_name.startswith(".") and ( @@ -95,10 +144,9 @@ def _zip( files_to_zip += [ directory_path / subfile_name for subfile_name in subfiles_names - if not any( - (directory_path / subfile_name).samefile(excluded_file) - for excluded_file in files_to_exclude - if excluded_file.exists() + if not excluded_file_ids + or not _is_excluded_archive_path( + directory_path / subfile_name, excluded_file_ids ) ] if globs_to_include: @@ -118,7 +166,11 @@ def _zip( for file in files_to_zip: logger.debug(f"{target}: {file}", extra={"progress": progress}) progress.advance(task) - zipf.write(file, Path(file).relative_to(root_directory)) + zipf.write( + file, + file.relative_to(root_directory), + compress_type=_zip_compress_type(component_name, file), + ) def zip_app( @@ -253,7 +305,31 @@ def build(): SystemExit: If the build process fails. """ wdir = prerequisites.get_web_dir() + command = [ + *js_runtimes.get_js_package_executor(raise_on_none=True)[0], + "run", + "export", + ] + with build_cache.frontend_build_cache(wdir, command) as cache: + if cache is None or not cache.restore(): + _build_frontend(wdir, command) + if cache is not None: + cache.capture() + _postprocess_frontend(wdir) + if cache is not None: + cache.commit() + +def _build_frontend(wdir: Path, command: list[str]) -> None: + """Run a fresh production JavaScript build. + + Args: + wdir: Frontend working directory. + command: Package manager export command. + + Raises: + SystemExit: The frontend build failed. + """ # Clean the static directory if it exists. path_ops.rm(str(wdir / constants.Dirs.BUILD_DIR)) @@ -266,11 +342,7 @@ def build(): # Start the subprocess with the progress bar. process = processes.new_process( - [ - *js_runtimes.get_js_package_executor(raise_on_none=True)[0], - "run", - "export", - ], + command, cwd=wdir, shell=constants.IS_WINDOWS, env=frontend_env(os.environ), @@ -282,6 +354,14 @@ def build(): "Failed to build the frontend. Please run with --loglevel debug for more information.", ) raise SystemExit(1) + + +def _postprocess_frontend(wdir: Path) -> None: + """Apply build hooks and serving transformations to pristine frontend output. + + Args: + wdir: Frontend working directory. + """ config = get_config() static_dir = wdir / constants.Dirs.STATIC diff --git a/reflex/utils/build_cache.py b/reflex/utils/build_cache.py new file mode 100644 index 00000000000..245a8ea5276 --- /dev/null +++ b/reflex/utils/build_cache.py @@ -0,0 +1,523 @@ +"""Opt-in local caching of pristine production frontend build output.""" + +from __future__ import annotations + +import errno +import hashlib +import json +import logging +import os +import shutil +import stat +import sys +import tempfile +import threading +import time +from collections.abc import Iterator, Sequence +from contextlib import contextmanager +from pathlib import Path +from typing import Protocol + +from reflex_base import constants +from reflex_base.environment import environment +from typing_extensions import Buffer + +from reflex.utils import path_ops + +logger = logging.getLogger(__name__) + +_CACHE_DIR = "reflex.build-cache" +_LOCK_FILE = ".reflex-build.lock" +_GENERATED_ROOT_ENTRIES = { + _CACHE_DIR, + _LOCK_FILE, + constants.Dirs.BUILD_DIR, + ".react-router", + "reflex.install_frontend_packages.cached", +} +_DEPENDENCY_CACHE_ENTRIES = {".vite", ".vite-temp", ".cache"} +_TELEMETRY_FIELDS = { + "last_reflex_run_datetime", + "last_version_check_datetime", + "last_version_check_attempt_datetime", +} +_VERSION_CHECK_PREFIXES = ( + "last_version_check_datetime_", + "last_version_check_attempt_datetime_", +) +_BUILD_ENVIRONMENT_PREFIXES = ("BUN_", "NODE_", "NPM_CONFIG_", "REFLEX_", "VITE_") +_IGNORED_BUILD_ENVIRONMENT_KEYS = {"REFLEX_LOGLEVEL"} +_lock_state = threading.local() +_lock_descriptors: set[int] = set() + + +class _Digest(Protocol): + """Protocol for the portion of a hash object used by cache fingerprints.""" + + def update(self, data: Buffer, /) -> None: + """Add data to the digest.""" + + +def _reset_build_locks_after_fork() -> None: + """Close inherited descriptors without unlocking the parent's file descriptions.""" + for descriptor in _lock_descriptors: + os.close(descriptor) + _lock_descriptors.clear() + _lock_state.paths = set() + + +if not constants.IS_WINDOWS: + os.register_at_fork(after_in_child=_reset_build_locks_after_fork) + + +def _lock_descriptor(descriptor: int, *, unlock: bool = False) -> None: + """Acquire or release the platform's exclusive file lock. + + Args: + descriptor: Open lock-file descriptor, positioned at byte zero. + unlock: Whether to release a previously acquired lock. + + Raises: + OSError: The lock operation fails for a reason other than contention. + """ + if sys.platform == "win32": + import msvcrt + + if unlock: + msvcrt.locking(descriptor, msvcrt.LK_UNLCK, 1) + return + while True: + try: + msvcrt.locking(descriptor, msvcrt.LK_NBLCK, 1) + except OSError as error: # noqa: PERF203 - retry a contended OS lock + if error.errno != errno.EACCES: + raise + # Poll every 100 ms; builds can exceed LK_LOCK's ten-second limit. + time.sleep(0.1) + else: + return + else: + import fcntl + + fcntl.flock(descriptor, fcntl.LOCK_UN if unlock else fcntl.LOCK_EX) + + +@contextmanager +def frontend_build_lock(web_dir: Path) -> Iterator[None]: + """Serialize production workspace access, including builds with caching disabled. + + Nested calls from the same thread reuse its lock. Other threads and processes + open independent descriptors and wait on the same persistent lock file. + + Args: + web_dir: Shared frontend working directory. + + Yields: + Control while this thread owns the production workspace. + + Raises: + OSError: A regular lock file cannot be opened or locked safely. + """ + owner_pid = os.getpid() + web_dir = web_dir.resolve() + held: set[Path] = getattr(_lock_state, "paths", set()) + if web_dir in held: + yield + return + web_dir.mkdir(parents=True, exist_ok=True) + lock_path = web_dir / _LOCK_FILE + descriptor = os.open( + lock_path, os.O_CREAT | os.O_RDWR | getattr(os, "O_NOFOLLOW", 0), 0o600 + ) + _lock_descriptors.add(descriptor) + try: + info = os.fstat(descriptor) + path_info = lock_path.lstat() + if ( + not stat.S_ISREG(info.st_mode) + or not stat.S_ISREG(path_info.st_mode) + or not os.path.samestat(info, path_info) + ): + msg = "Production build lock must be a regular file" + raise OSError(msg) + _lock_descriptor(descriptor) + held.add(web_dir) + _lock_state.paths = held + try: + yield + finally: + if os.getpid() == owner_pid: + held.remove(web_dir) + _lock_descriptor(descriptor, unlock=True) + finally: + if os.getpid() == owner_pid: + _lock_descriptors.discard(descriptor) + os.close(descriptor) + + +def _is_generated(relative: Path) -> bool: + """Identify paths omitted from the frontend input fingerprint. + + Args: + relative: Path relative to the frontend working directory. + + Returns: + Whether the path belongs to generated output or a transient cache. + """ + return bool(relative.parts) and ( + relative.parts[0] in _GENERATED_ROOT_ENTRIES + or ( + len(relative.parts) >= 2 + and relative.parts[0] == "node_modules" + and relative.parts[1] in _DEPENDENCY_CACHE_ENTRIES + ) + ) + + +def _remove_cache_entry(path: Path) -> None: + """Remove local cache data without following or chmodding a symlink target. + + Args: + path: Cache entry to discard. + """ + if path.is_symlink() or path.is_file(): + path.unlink() + elif path.exists(): + shutil.rmtree(path) + + +def _digest_symlink(digest: _Digest, root: Path, path: Path, *, inputs: bool) -> None: + """Hash a tracked symlink and validate its resolved target. + + Args: + digest: The digest receiving the symlink identity. + root: The root of the tracked tree. + path: The symlink path. + inputs: Whether this is the frontend input tree. + + Raises: + ValueError: The link loops, leaves the tree, or reaches generated output. + """ + try: + target = path.resolve(strict=True) + except RuntimeError as error: + msg = "Build input contains a symlink loop" + raise ValueError(msg) from error + if not inputs or not target.is_relative_to(root): + msg = "Build cache cannot track an external symlink" + raise ValueError(msg) + target_relative = target.relative_to(root) + if _is_generated(target_relative): + msg = "Build input links to an untracked generated directory" + raise ValueError(msg) + digest.update( + json.dumps([str(path.readlink()), target_relative.as_posix()]).encode() + ) + + +def _digest_regular_file( + digest: _Digest, path: Path, relative: Path, info: os.stat_result, *, inputs: bool +) -> None: + """Hash a file's content or installed-dependency metadata. + + Args: + digest: The digest receiving file metadata. + path: The file path. + relative: The path relative to the tracked tree. + info: The file status metadata. + inputs: Whether this is the frontend input tree. + + Raises: + ValueError: Frontend metadata is not a JSON object. + """ + if inputs and relative.parts[0] == "node_modules": + digest.update( + json.dumps([ + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + ]).encode() + ) + return + if inputs and relative.as_posix() == constants.Reflex.JSON: + metadata = json.loads(path.read_text()) + if not isinstance(metadata, dict): + msg = "Frontend metadata must be an object" + raise ValueError(msg) + digest.update( + json.dumps( + { + key: value + for key, value in metadata.items() + if key not in _TELEMETRY_FIELDS + and not key.startswith(_VERSION_CHECK_PREFIXES) + }, + sort_keys=True, + ).encode() + ) + return + content_digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(1024 * 1024): + content_digest.update(chunk) + digest.update(content_digest.digest()) + + +def _digest_entry( + digest: _Digest, + root: Path, + path: Path, + relative: Path, + info: os.stat_result, + *, + inputs: bool, +) -> Path | None: + """Hash one tree entry and return a directory that should be visited. + + Args: + digest: The digest receiving entry data. + root: The root of the tracked tree. + path: The entry path. + relative: The path relative to the tracked tree. + info: The entry status metadata. + inputs: Whether this is the frontend input tree. + + Returns: + A physical child directory to visit, if applicable. + + Raises: + ValueError: The entry is unsupported or cannot be safely tracked. + """ + if stat.S_ISLNK(info.st_mode): + _digest_symlink(digest, root, path, inputs=inputs) + return None + if stat.S_ISDIR(info.st_mode): + return path + if stat.S_ISREG(info.st_mode): + _digest_regular_file(digest, path, relative, info, inputs=inputs) + return None + msg = "Build cache only supports regular files and directories" + raise ValueError(msg) + + +def _tree_digest(root: Path, *, inputs: bool = False) -> str: + """Hash tracked tree entries, using change metadata for installed dependencies. + + Args: + root: Directory to inspect without following directory symlinks. + inputs: Whether this is the frontend input tree rather than a snapshot. + + Returns: + A digest of names, file types, modes, and file content or dependency metadata. + + Raises: + OSError: A file cannot be inspected or links outside the tracked tree. + ValueError: The tree contains unsupported file types or invalid metadata. + """ + if root.is_symlink(): + msg = "Build cache cannot inspect a symlink as its root" + raise ValueError(msg) + digest = hashlib.sha256() + + def visit(directory: Path) -> None: + """Hash entries in one directory and descend into physical directories. + + Args: + directory: Directory within the tracked tree. + """ + with os.scandir(directory) as entries: + ordered_entries = sorted(entries, key=lambda entry: entry.name) + for entry in ordered_entries: + path = Path(entry.path) + relative = path.relative_to(root) + if inputs and _is_generated(relative): + continue + info = entry.stat(follow_symlinks=False) + digest.update(json.dumps([relative.as_posix(), info.st_mode]).encode()) + if child_directory := _digest_entry( + digest, root, path, relative, info, inputs=inputs + ): + visit(child_directory) + digest.update(b"\0") + + visit(root) + return digest.hexdigest() + + +def _build_environment() -> list[tuple[str, str]]: + """Return environment values that can affect a Vite production build. + + Returns: + Sorted build-relevant environment key-value pairs. + """ + return sorted( + (key, value) + for key, value in os.environ.items() + if (key == "PATH" or key.startswith(_BUILD_ENVIRONMENT_PREFIXES)) + and key not in _IGNORED_BUILD_ENVIRONMENT_KEYS + ) + + +def _input_digest(web_dir: Path, command: Sequence[str | Path]) -> str: + """Fingerprint local build inputs and the executing runtime. + + Args: + web_dir: Resolved frontend working directory. + command: Production export command. + + Returns: + A digest suitable for comparing builds on this machine. + + Raises: + OSError: Installed dependencies or a runtime are missing. + ValueError: An input cannot safely be tracked. + """ + if not (web_dir / "node_modules").is_dir(): + msg = "Installed frontend dependencies are missing" + raise ValueError(msg) + runtime_paths = {str(command[0])} + if node := path_ops.get_node_path(): + runtime_paths.add(str(node)) + runtimes = [] + for runtime in sorted(runtime_paths): + path = Path(shutil.which(runtime) or runtime).resolve(strict=True) + info = path.stat() + runtimes.append(( + str(path), + info.st_dev, + info.st_ino, + info.st_size, + info.st_mtime_ns, + info.st_ctime_ns, + )) + payload = [ + 3, # Discard snapshots created before build-environment filtering. + str(web_dir), + constants.Reflex.VERSION, + [str(arg) for arg in command], + runtimes, + _build_environment(), + _tree_digest(web_dir, inputs=True), + ] + return hashlib.sha256(json.dumps(payload).encode()).hexdigest() + + +class _FrontendBuildCache: + """A single pending snapshot, published only after a successful full build.""" + + def __init__(self, web_dir: Path, command: Sequence[str | Path]): + """Record the inputs before running Vite. + + Args: + web_dir: Resolved frontend directory. + command: Production export command. + """ + self.web_dir = web_dir + self.command = command + self.directory = web_dir / _CACHE_DIR + self.current = self.directory / "current" + self.key = _input_digest(web_dir, command) + self.pending: Path | None = None + + def restore(self) -> bool: + """Restore a verified pristine snapshot. + + Returns: + Whether Vite can be skipped. + """ + snapshot = self.current / "build" + try: + if self.current.is_symlink() or snapshot.is_symlink(): + return False + metadata = json.loads((self.current / "metadata.json").read_text()) + if metadata != {"input": self.key, "output": _tree_digest(snapshot)}: + return False + _remove_cache_entry(self.web_dir / constants.Dirs.BUILD_DIR) + shutil.copytree(snapshot, self.web_dir / constants.Dirs.BUILD_DIR) + except (OSError, ValueError): + return False + logger.info("Reusing cached production frontend build.") + return True + + def capture(self) -> None: + """Copy pristine Vite output before post-build hooks can mutate it.""" + try: + self.directory.mkdir(exist_ok=True) + self.pending = Path(tempfile.mkdtemp(prefix="pending-", dir=self.directory)) + source = self.web_dir / constants.Dirs.BUILD_DIR + output_digest = _tree_digest(source) + shutil.copytree(source, self.pending / "build") + if _tree_digest(self.pending / "build") != output_digest: + self.close() + return + (self.pending / "metadata.json").write_text( + json.dumps({ + "input": self.key, + "output": output_digest, + }) + ) + except (OSError, ValueError): + self.close() + logger.debug( + "Could not snapshot frontend output; continuing without caching." + ) + + def commit(self) -> None: + """Publish only if the complete build succeeded and tracked inputs stayed stable.""" + if self.pending is None: + return + try: + if _input_digest(self.web_dir, self.command) != self.key: + return + _remove_cache_entry(self.current) + self.pending.replace(self.current) + self.pending = None + except (OSError, ValueError): + logger.debug( + "Could not publish frontend cache; continuing without caching." + ) + + def close(self) -> None: + """Remove an unpublished snapshot without affecting the build result.""" + if self.pending is not None: + shutil.rmtree(self.pending, ignore_errors=True) + self.pending = None + + +@contextmanager +def frontend_build_cache( + web_dir: Path, command: Sequence[str | Path] +) -> Iterator[_FrontendBuildCache | None]: + """Manage the explicitly enabled local frontend build cache. + + Args: + web_dir: Frontend working directory. + command: Production export command. + + Yields: + A cache handle, or None when disabled or local inputs cannot be tracked. + """ + with frontend_build_lock(web_dir): + web_dir = web_dir.resolve() + directory = web_dir / _CACHE_DIR + enabled = ( + environment.REFLEX_FRONTEND_BUILD_CACHE.get() and not constants.IS_WINDOWS + ) + cache = None + try: + if not enabled: + # A forced fresh build must not leave an older reusable snapshot. + _remove_cache_entry(directory) + elif not directory.is_symlink(): + cache = _FrontendBuildCache(web_dir, command) + except (OSError, ValueError): + logger.debug( + "Frontend cache unavailable; running a fresh production build." + ) + try: + yield cache + finally: + if cache is not None: + cache.close() diff --git a/reflex/utils/export.py b/reflex/utils/export.py index 565d0cb0915..d3bce4f6f0b 100644 --- a/reflex/utils/export.py +++ b/reflex/utils/export.py @@ -3,7 +3,7 @@ import logging import time from collections.abc import Iterator -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from pathlib import Path from reflex_base import constants @@ -11,7 +11,7 @@ from reflex_base.environment import environment from reflex_base.utils import console -from reflex.utils import build, exec, prerequisites, telemetry +from reflex.utils import build, build_cache, exec, prerequisites, telemetry logger = logging.getLogger(__name__) @@ -49,23 +49,6 @@ def export( # Set the log level. console.set_log_level(loglevel) - # Set env mode in the environment - environment.REFLEX_ENV_MODE.set(env) - - # Override the config url values if provided. - if api_url is not None: - config._set_persistent(api_url=str(api_url)) - logger.debug(f"overriding API URL: {config.api_url}") - if deploy_url is not None: - config._set_persistent(deploy_url=str(deploy_url)) - logger.debug(f"overriding deploy URL: {config.deploy_url}") - - # Show system info - exec.output_system_info() - - # Compile the app in production mode and export it. - console.rule("[bold]Compiling production app and preparing for export.") - start = time.monotonic() phase_durations: dict[str, float] = {} status = "success" @@ -80,26 +63,45 @@ def _time_phase(name: str) -> Iterator[None]: phase_durations[name] = time.monotonic() - t0 try: - if frontend: - with _time_phase("compile_duration"): - # Ensure module can be imported and app.compile() is called. - prerequisites.get_compiled_app( - prerender_routes=prerender_routes, trigger="export" - ) - with _time_phase("setup_duration"): - # Set up .web directory and install frontend dependencies. - build.setup_frontend(Path.cwd()) - with _time_phase("build_duration"): - build.build() - if zipping: - with _time_phase("zip_duration"): - build.zip_app( - frontend=frontend, - backend=backend, - zip_dest_dir=zip_dest_dir, - include_db_file=upload_db_file, - backend_excluded_dirs=backend_excluded_dirs, - ) + with ( + build_cache.frontend_build_lock(prerequisites.get_web_dir()) + if frontend + else nullcontext() + ): + # Set env mode in the environment. + environment.REFLEX_ENV_MODE.set(env) + + # Override the config url values if provided. + if api_url is not None: + config._set_persistent(api_url=str(api_url)) + logger.debug(f"overriding API URL: {config.api_url}") + if deploy_url is not None: + config._set_persistent(deploy_url=str(deploy_url)) + logger.debug(f"overriding deploy URL: {config.deploy_url}") + + exec.output_system_info() + console.rule("[bold]Compiling production app and preparing for export.") + + if frontend: + with _time_phase("compile_duration"): + # Ensure module can be imported and app.compile() is called. + prerequisites.get_compiled_app( + prerender_routes=prerender_routes, trigger="export" + ) + with _time_phase("setup_duration"): + # Set up .web directory and install frontend dependencies. + build.setup_frontend(Path.cwd()) + with _time_phase("build_duration"): + build.build() + if zipping: + with _time_phase("zip_duration"): + build.zip_app( + frontend=frontend, + backend=backend, + zip_dest_dir=zip_dest_dir, + include_db_file=upload_db_file, + backend_excluded_dirs=backend_excluded_dirs, + ) except Exception as exc: status = "failure" detail = type(exc).__name__ diff --git a/reflex/utils/frontend_skeleton.py b/reflex/utils/frontend_skeleton.py index 59ab8b20cd0..70b733b2a3e 100644 --- a/reflex/utils/frontend_skeleton.py +++ b/reflex/utils/frontend_skeleton.py @@ -367,8 +367,20 @@ def sync_root_package_json_to_web() -> bool: output_path = get_web_lockfile_path(constants.PackageJson.PATH) rendered = _compile_package_json() - if output_path.exists() and output_path.read_text() == rendered: - return False + if output_path.exists(): + existing = output_path.read_text() + if existing == rendered: + return False + try: + # Package managers reformat this file after installs. Preserve their + # formatting when every JSON value is unchanged, including its type. + existing_json = json.dumps(json.loads(existing), sort_keys=True) + rendered_json = json.dumps(json.loads(rendered), sort_keys=True) + except json.JSONDecodeError: + pass + else: + if existing_json == rendered_json: + return False changed = output_path.exists() path_ops.mkdir(output_path.parent) diff --git a/reflex/utils/js_runtimes.py b/reflex/utils/js_runtimes.py index eedd09d6df9..048ce83a04d 100644 --- a/reflex/utils/js_runtimes.py +++ b/reflex/utils/js_runtimes.py @@ -4,6 +4,7 @@ import json import logging import os +import re import tempfile from collections.abc import Sequence from pathlib import Path @@ -422,7 +423,7 @@ def _extract_package_name(package_spec: str) -> str: return package_spec.split("@", 1)[0] -def _existing_web_package_sections() -> tuple[set[str], set[str]]: +def _existing_web_package_sections() -> tuple[dict[str, str], dict[str, str]]: """Return packages currently declared in .web/package.json by section. Reads ``.web/package.json``'s ``dependencies`` and ``devDependencies`` @@ -431,24 +432,24 @@ def _existing_web_package_sections() -> tuple[set[str], set[str]]: deps. Returns: - A tuple ``(deps, dev_deps)`` of bare package names. Both empty if + A tuple ``(deps, dev_deps)`` mapping package names to versions. Both empty if the file is missing or unreadable. """ web_pkg_json_path = frontend_skeleton.get_web_lockfile_path( constants.PackageJson.PATH ) if not web_pkg_json_path.exists(): - return set(), set() + return {}, {} try: data = json.loads(web_pkg_json_path.read_text()) except (json.JSONDecodeError, OSError) as e: logger.warning( f"Failed to read {web_pkg_json_path}: {e}; skipping existing package check." ) - return set(), set() + return {}, {} return ( - set(data.get("dependencies") or {}), - set(data.get("devDependencies") or {}), + data.get("dependencies") or {}, + data.get("devDependencies") or {}, ) @@ -468,6 +469,108 @@ def _is_bun_package_manager(package_manager: str) -> bool: return Path(package_manager).stem.lower() == "bun" +def _verified_installed_version( + name: str, declaration: str, entry: object +) -> str | None: + """Return a verified installed version for one npm caret declaration. + + Args: + name: The package name. + declaration: The version declared in the package manifest. + entry: The package-lock entry for the installed package. + + Returns: + The installed version when its lock and package metadata both match. + """ + if ( + not isinstance(entry, dict) + or entry.get("link") + or entry.get("name", name) != name + ): + return None + installed_version = entry.get("version") + if not isinstance(installed_version, str) or declaration != f"^{installed_version}": + return None + resolved = entry.get("resolved") + if isinstance(resolved, str) and resolved.startswith(( + "file:", + "link:", + "workspace:", + )): + return None + package_dir = get_web_dir() / "node_modules" / name + if package_dir.is_symlink(): + return None + try: + package = json.loads((package_dir / "package.json").read_text()) + except (OSError, ValueError): + return None + if ( + isinstance(package, dict) + and package.get("name") == name + and package.get("version") == installed_version + ): + return installed_version + return None + + +def _npm_lock_package_data() -> tuple[dict, dict] | None: + """Read the root and package metadata from a supported npm lockfile. + + Returns: + The root package declarations and installed package metadata, if usable. + """ + try: + lock = json.loads( + frontend_skeleton.get_web_lockfile_path( + constants.Node.LOCKFILE_PATH + ).read_text() + ) + except (OSError, ValueError): + return None + if not isinstance(lock, dict) or lock.get("lockfileVersion") not in (2, 3): + return None + packages = lock.get("packages") + if not isinstance(packages, dict) or not isinstance(root := packages.get(""), dict): + return None + return root, packages + + +def _npm_installed_package_sections( + declared_deps: dict[str, str], declared_dev_deps: dict[str, str] +) -> tuple[dict[str, str], dict[str, str]]: + """Verify npm's saved caret declarations against its lockfile and installed packages. + + Args: + declared_deps: Regular dependency declarations used for the install. + declared_dev_deps: Development dependency declarations used for the install. + + Returns: + Dependency sections with verified caret declarations replaced by their + exact installed versions. Unverified declarations remain unchanged. + """ + if (lock_data := _npm_lock_package_data()) is None: + return declared_deps, declared_dev_deps + root, packages = lock_data + + installed_deps, installed_dev_deps = dict(declared_deps), dict(declared_dev_deps) + for section, declared, installed in ( + ("dependencies", declared_deps, installed_deps), + ("devDependencies", declared_dev_deps, installed_dev_deps), + ): + root_declarations = root.get(section, {}) + if not isinstance(root_declarations, dict): + continue + for name, declaration in declared.items(): + if root_declarations.get(name) != declaration: + continue + if installed_version := _verified_installed_version( + name, declaration, packages.get(f"node_modules/{name}") + ): + installed[name] = installed_version + return installed_deps, installed_dev_deps + + def _run_initial_install( primary_package_manager: str, env: dict, frozen_lockfile: bool ) -> None: @@ -494,6 +597,8 @@ def _run_initial_install( "install", "--legacy-peer-deps", ] + if not _is_bun_package_manager(primary_package_manager): + install_args.append("--include=dev") if frozen_lockfile and _is_bun_package_manager(primary_package_manager): # ``--frozen-lockfile`` is bun-only; npm ignores it today and the # next major rejects unknown flags outright. @@ -571,16 +676,32 @@ def _split_by_version_specifier( return pinned, unpinned -def _pinned_args_from_constants(deps: dict[str, str]) -> set[str]: +def _pinned_args_from_constants( + deps: dict[str, str], + installed: dict[str, str] | None = None, + explicitly_requested: set[str] | None = None, +) -> set[str]: """Render constants-style dep dicts as ``name@version`` add args. Args: deps: Mapping of package name to version string. + installed: Dependencies already installed from a restored lockfile. + explicitly_requested: Package names with component or plugin pins whose + existing resolution behavior must be preserved. Returns: Set of ``name@version`` specs. """ - return {f"{name}@{version}" for name, version in deps.items()} + return { + f"{name}@{version}" + for name, version in deps.items() + if installed is None + or installed.get(name) != version + or (explicitly_requested is not None and name in explicitly_requested) + or not re.fullmatch( + r"[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version + ) + } def _frontend_packages_cache_payload( @@ -624,8 +745,8 @@ def _install_frontend_packages( Resolution rules: * Framework deps in :attr:`constants.PackageJson.DEPENDENCIES` and :attr:`constants.PackageJson.DEV_DEPENDENCIES` always carry version - specifiers and are added with strict pins so they overwrite any - existing entry in package.json. + specifiers and are added with strict pins. Matching entries in the + correct section are reused after a successful lockfile install. * Plugin/custom packages with explicit version specifiers are also added with strict pins. * Plugin/custom packages without version specifiers are skipped @@ -658,6 +779,8 @@ def _install_frontend_packages( ) primary_package_manager = install_package_managers[0] + is_bun = _is_bun_package_manager(primary_package_manager) + include_dev_args = [] if is_bun else ["--include=dev"] # No fallback to a different package manager: switching mid-flow could # bypass the persisted lockfile (e.g. on a package-integrity failure @@ -686,7 +809,8 @@ def _install_frontend_packages( ) - wanted_dep_names needed_names = wanted_dep_names | wanted_dev_dep_names - existing_deps, existing_dev_deps = _existing_web_package_sections() + declared_deps, declared_dev_deps = _existing_web_package_sections() + existing_deps, existing_dev_deps = set(declared_deps), set(declared_dev_deps) existing_names = existing_deps | existing_dev_deps # Drop deps lingering in package.json that no component, plugin, or @@ -704,6 +828,7 @@ def _install_frontend_packages( primary_package_manager, "remove", "--legacy-peer-deps", + *include_dev_args, *sorted(to_remove), ], show_status_message="Removing unused frontend packages", @@ -711,16 +836,22 @@ def _install_frontend_packages( # Install against the recovered lockfile so its pins are honored # before any further mutation. - if any( + has_lockfile = any( frontend_skeleton.get_web_lockfile_path(name).exists() for name in frontend_skeleton.LOCKFILE_NAMES - ): + ) + if has_lockfile: _run_initial_install(primary_package_manager, env, frozen_lockfile) # Framework overrides are withheld while the persisted package.json is # restored so the frozen install above sees exactly the file that produced # the persisted lockfile. Merge them now, before any resolution happens. overrides_changed = frontend_skeleton.update_package_json_overrides() + installed_deps, installed_dev_deps = declared_deps, declared_dev_deps + if has_lockfile and not is_bun and not overrides_changed: + installed_deps, installed_dev_deps = _npm_installed_package_sections( + declared_deps, declared_dev_deps + ) pinned_packages, unpinned_packages = _split_by_version_specifier(packages) pinned_dev_deps, unpinned_dev_deps = _split_by_version_specifier( @@ -737,19 +868,28 @@ def _install_frontend_packages( new_unpinned_dev_deps = unpinned_dev_deps - existing_dev_deps deps_to_add = ( - _pinned_args_from_constants(constants.PackageJson.DEPENDENCIES) + _pinned_args_from_constants( + constants.PackageJson.DEPENDENCIES, + installed_deps if has_lockfile else None, + explicitly_requested={_extract_package_name(p) for p in pinned_packages}, + ) | pinned_packages | new_unpinned_packages ) - deps_names_to_add = {_extract_package_name(p) for p in deps_to_add} dev_deps_to_add = { spec for spec in ( - _pinned_args_from_constants(constants.PackageJson.DEV_DEPENDENCIES) + _pinned_args_from_constants( + constants.PackageJson.DEV_DEPENDENCIES, + installed_dev_deps if has_lockfile else None, + explicitly_requested={ + _extract_package_name(p) for p in pinned_dev_deps + }, + ) | pinned_dev_deps | new_unpinned_dev_deps ) - if _extract_package_name(spec) not in deps_names_to_add + if _extract_package_name(spec) not in wanted_dep_names } # Add dev dependencies first so that any subsequent regular-dep add @@ -761,14 +901,21 @@ def _install_frontend_packages( primary_package_manager, "add", "--legacy-peer-deps", - "-d", + *include_dev_args, + "-d" if is_bun else "--save-dev", *dev_deps_to_add, ], show_status_message="Installing frontend development dependencies", ) if deps_to_add: run_package_manager( - [primary_package_manager, "add", "--legacy-peer-deps", *deps_to_add], + [ + primary_package_manager, + "add", + "--legacy-peer-deps", + *include_dev_args, + *deps_to_add, + ], show_status_message="Installing frontend packages", ) @@ -776,7 +923,12 @@ def _install_frontend_packages( # Newly merged overrides with no add to carry them into the lockfile: # resolve them now so the persisted pair stays frozen-install ready. run_package_manager( - [primary_package_manager, "install", "--legacy-peer-deps"], + [ + primary_package_manager, + "install", + "--legacy-peer-deps", + *include_dev_args, + ], show_status_message="Applying frontend package overrides", ) diff --git a/reflex/utils/prerequisites.py b/reflex/utils/prerequisites.py index 3eafd4df6c1..f063efb521a 100644 --- a/reflex/utils/prerequisites.py +++ b/reflex/utils/prerequisites.py @@ -12,7 +12,7 @@ import sys import typing import uuid -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from os import getcwd from pathlib import Path from types import ModuleType @@ -32,7 +32,9 @@ logger = logging.getLogger(__name__) +# Reuse successful checks for 24 hours. _LATEST_VERSION_CHECK_INTERVAL = timedelta(days=1) +# Retry failed checks after one hour. _LATEST_VERSION_CHECK_FAILURE_INTERVAL = timedelta(hours=1) _LATEST_VERSION_CHECK_DATETIME_KEY = "last_version_check_datetime" _LATEST_VERSION_CHECK_ATTEMPT_DATETIME_KEY = "last_version_check_attempt_datetime" @@ -99,13 +101,18 @@ def check_latest_package_version(package_name: str): current_version = importlib.metadata.version(package_name) url = f"https://pypi.org/pypi/{package_name}/json" response = net.get(url, timeout=2) + response.raise_for_status() latest_version = response.json()["info"]["version"] logger.debug(f"Latest version of {package_name}: {latest_version}") current_version_parsed = version.parse(current_version) latest_version_parsed = version.parse(latest_version) path_ops.update_json_file( get_web_dir() / constants.Reflex.JSON, - {_version_check_timestamp_key(package_name): str(datetime.now())}, + { + _version_check_timestamp_key(package_name): datetime.now( + timezone.utc + ).isoformat() + }, ) if current_version_parsed < latest_version_parsed: # Show a warning when the host version is older than PyPI version @@ -157,7 +164,7 @@ def get_or_set_last_reflex_version_check_datetime( return None data = json.loads(reflex_json_file.read_text()) - now = datetime.now() + now = datetime.now(timezone.utc) for key, interval in ( ( _version_check_timestamp_key(package_name), @@ -172,7 +179,8 @@ def get_or_set_last_reflex_version_check_datetime( if not isinstance(timestamp, str): continue try: - elapsed = now - datetime.fromisoformat(timestamp) + checked_at = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + elapsed = datetime.now(checked_at.tzinfo) - checked_at except (TypeError, ValueError): continue else: @@ -181,7 +189,7 @@ def get_or_set_last_reflex_version_check_datetime( path_ops.update_json_file( reflex_json_file, - {_version_check_timestamp_key(package_name, attempt=True): str(now)}, + {_version_check_timestamp_key(package_name, attempt=True): now.isoformat()}, ) return None diff --git a/tests/units/reflex_cli/v2/test_cli.py b/tests/units/reflex_cli/v2/test_cli.py index 8a850882911..eeed93e3109 100644 --- a/tests/units/reflex_cli/v2/test_cli.py +++ b/tests/units/reflex_cli/v2/test_cli.py @@ -1160,6 +1160,24 @@ def test_deploy_without_instance_bounds_flags_skips_the_call( recorder.create_deployment.assert_called_once() +def test_deploy_non_interactive_skips_provider_availability_lookup( + mocker: MockerFixture, + mock_export_fn: MagicMock, +): + """A non-interactive deploy exports and submits without probing unused targets.""" + recorder = _deploy_call_recorder(mocker) + available = mocker.patch( + "reflex_cli.utils.hosting.gcp_deploy_available", + return_value={"configured": True, "allowed": True}, + ) + + cli.deploy(app_name="fake-app", export_fn=mock_export_fn, interactive=False) + + available.assert_not_called() + assert mock_export_fn.call_count == 2 + recorder.create_deployment.assert_called_once() + + def test_deploy_failed_export_does_not_apply_instance_bounds( mocker: MockerFixture, mock_export_import_error_fn: Callable[[str, str, str, bool, bool, bool], None], @@ -1549,19 +1567,25 @@ def test_resolve_deploy_provider_reflex_cloud_no_switch(mocker: MockFixture): mock_set.assert_not_called() -def test_resolve_deploy_provider_non_interactive_keeps_current(mocker: MockFixture): - """Non-interactive with no --provider keeps the app's provider, no prompt.""" +@pytest.mark.parametrize("provider", [None, "fly", "gcp"]) +def test_resolve_deploy_provider_non_interactive_keeps_current( + mocker: MockFixture, provider: str | None +): + """Non-interactive deploys keep their provider without a lookup or prompt.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) - mocker.patch( + available = mocker.patch( "reflex_cli.utils.hosting.gcp_deploy_available", return_value={"configured": True, "allowed": True}, ) + ask = mocker.patch("reflex_cli.utils.console.ask") mock_set = mocker.patch("reflex_cli.utils.hosting.set_app_provider") - app = {"id": "app-1", "name": "myapp", "provider": "fly"} + app = {"id": "app-1", "name": "myapp", "provider": provider} result = cli._resolve_deploy_provider( app, None, interactive=False, app_was_created=False, client=client ) - assert result == "fly" + assert result == provider + available.assert_not_called() + ask.assert_not_called() mock_set.assert_not_called() @@ -1601,7 +1625,7 @@ def test_resolve_deploy_provider_switch_confirm_defaults_to_cancel( def test_resolve_deploy_provider_interactive_prompt_selects_gcp(mocker: MockFixture): """When GCP is available and the user picks it, the app switches to GCP.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) - mocker.patch( + available = mocker.patch( "reflex_cli.utils.hosting.gcp_deploy_available", return_value={"configured": True, "allowed": True, "region": "us-central1"}, ) @@ -1614,6 +1638,7 @@ def test_resolve_deploy_provider_interactive_prompt_selects_gcp(mocker: MockFixt app, None, interactive=True, app_was_created=True, client=client ) assert result == "gcp" + available.assert_called_once_with(client) mock_set.assert_called_once() @@ -1690,8 +1715,9 @@ def test_resolve_deploy_provider_named_connection_is_pinned(mocker: MockFixture) ) +@pytest.mark.parametrize("provider_arg", [None, "gcp"]) def test_resolve_deploy_provider_repoints_without_a_provider_switch( - mocker: MockFixture, + mocker: MockFixture, provider_arg: str | None ): """An app already on GCP is still repointed when a connection is named.""" client = hosting.AuthenticatedClient(token="t", validated_data={}) @@ -1706,7 +1732,7 @@ def test_resolve_deploy_provider_repoints_without_a_provider_switch( result = cli._resolve_deploy_provider( app, - "gcp", + provider_arg, interactive=False, app_was_created=False, client=client, diff --git a/tests/units/test_prerequisites.py b/tests/units/test_prerequisites.py index 1b5c9766f5a..c577c9a5a7e 100644 --- a/tests/units/test_prerequisites.py +++ b/tests/units/test_prerequisites.py @@ -1,5 +1,7 @@ import importlib.metadata import json +import logging +import os import shutil import subprocess import sys @@ -7,9 +9,10 @@ import uuid from collections.abc import Callable, Generator from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Protocol +from unittest.mock import Mock import pytest from click.testing import CliRunner @@ -142,7 +145,7 @@ def test_check_latest_package_version_refreshes_expired_check( }) ) installed_version, request = _mock_pypi_versions(mocker) - before_check = datetime.now() + before_check = datetime.now(timezone.utc) with caplog.at_level("WARNING"): prerequisites.check_latest_package_version("reflex") @@ -152,7 +155,7 @@ def test_check_latest_package_version_refreshes_expired_check( ) installed_version.assert_called_once_with("reflex") request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) - assert before_check <= checked_at <= datetime.now() + assert before_check <= checked_at <= datetime.now(timezone.utc) assert caplog.messages == [ ( "Your version (1.0.0) of reflex is out of date. Upgrade to 2.0.0 " @@ -209,7 +212,7 @@ def test_check_latest_package_version_repairs_invalid_timestamp( "last_version_check_datetime" ] assert refreshed_timestamp != stored_timestamp - assert datetime.fromisoformat(refreshed_timestamp) <= datetime.now() + assert datetime.fromisoformat(refreshed_timestamp) <= datetime.now(timezone.utc) def test_check_latest_package_version_throttles_failed_request( @@ -302,6 +305,173 @@ def test_check_latest_package_version_can_be_disabled( assert json.loads(version_check_file.read_text()) == {} +@pytest.fixture +def version_check_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + """Isolate the persisted version check and its PyPI request. + + Args: + tmp_path: The temporary directory fixture. + monkeypatch: The monkeypatch fixture. + + Returns: + The reflex.json path and the mocked PyPI request. + """ + reflex_json = tmp_path / constants.Reflex.JSON + reflex_json.write_text(json.dumps({"project_hash": "test-project"})) + monkeypatch.setattr(prerequisites, "get_web_dir", lambda: tmp_path) + monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "True") + monkeypatch.setattr(prerequisites.importlib.metadata, "version", lambda _: "1.0.0") + + class FrozenDatetime(datetime): + @classmethod + def now(cls, tz=None): + if tz is None: + return cls(2026, 9, 6, 12) + return cls(2026, 9, 6, 12, tzinfo=timezone.utc).astimezone(tz) + + monkeypatch.setattr(prerequisites, "datetime", FrozenDatetime) + response = Mock() + response.json.return_value = {"info": {"version": "2.0.0"}} + request = Mock(return_value=response) + monkeypatch.setattr(prerequisites.net, "get", request) + return reflex_json, request + + +@pytest.mark.parametrize( + "timestamp", + [ + "2026-09-06 11:00:00", + "2026-09-06T12:00:00+00:00", + "2026-09-06T13:00:00+02:00", + "2026-09-06T11:00:00Z", + "2026-09-05T12:00:01+00:00", + ], +) +def test_check_latest_package_version_skips_cached_request( + version_check_env, timestamp +): + """A check less than one day old avoids another PyPI request.""" + reflex_json, request = version_check_env + contents = json.dumps({"last_version_check_datetime": timestamp}) + reflex_json.write_text(contents) + + prerequisites.check_latest_package_version("reflex") + + request.assert_not_called() + assert reflex_json.read_text() == contents + + +@pytest.mark.parametrize( + "timestamp", + [ + "2026-09-01 12:00:00", + "2026-09-05T12:00:00+00:00", + "2026-09-05T14:00:00+02:00", + "2026-09-07 12:00:00", + "2026-09-06T12:00:01+00:00", + "2026-09-06T13:00:00-02:00", + "not-a-date", + 123, + ["2026-09-06 11:00:00"], + None, + ], +) +def test_check_latest_package_version_refreshes_stale_or_invalid_timestamp( + version_check_env, timestamp, caplog: pytest.LogCaptureFixture +): + """Expired, invalid, and future records refresh and allow a new upgrade warning.""" + reflex_json, request = version_check_env + reflex_json.write_text( + json.dumps({ + "project_hash": "test-project", + "last_version_check_datetime": timestamp, + }) + ) + + with caplog.at_level(logging.WARNING, logger=prerequisites.__name__): + prerequisites.check_latest_package_version("reflex") + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) + assert json.loads(reflex_json.read_text()) == { + "project_hash": "test-project", + "last_version_check_datetime": "2026-09-06T12:00:00+00:00", + "last_version_check_attempt_datetime": "2026-09-06T12:00:00+00:00", + } + assert sum(record.levelno == logging.WARNING for record in caplog.records) == 1 + + +@pytest.mark.parametrize("failure", ["network", "http", "json", "version"]) +def test_check_latest_package_version_retries_failed_refresh( + version_check_env, failure +): + """Failed refreshes preserve the old record and retry after the failure cooldown.""" + reflex_json, request = version_check_env + contents = json.dumps({"last_version_check_datetime": "2026-09-01 12:00:00"}) + reflex_json.write_text(contents) + failed_response = Mock() + failed_response.json.return_value = { + "info": {"version": "not-a-version" if failure == "version" else "2.0.0"} + } + if failure == "http": + failed_response.raise_for_status.side_effect = OSError("HTTP failure") + elif failure == "json": + failed_response.json.side_effect = ValueError("Invalid JSON") + request.side_effect = [ + OSError("network unavailable") if failure == "network" else failed_response, + request.return_value, + ] + + prerequisites.check_latest_package_version("reflex") + data = json.loads(reflex_json.read_text()) + assert data["last_version_check_datetime"] == "2026-09-01 12:00:00" + prerequisites.check_latest_package_version("reflex") + assert request.call_count == 1 + data["last_version_check_attempt_datetime"] = "2026-09-06T11:00:00+00:00" + reflex_json.write_text(json.dumps(data)) + prerequisites.check_latest_package_version("reflex") + assert request.call_count == 2 + assert json.loads(reflex_json.read_text())["last_version_check_datetime"] == ( + "2026-09-06T12:00:00+00:00" + ) + + +@pytest.mark.parametrize("latest_version", ["1.0.0", "2.0.0"]) +def test_check_latest_package_version_caches_success( + version_check_env, latest_version: str, caplog: pytest.LogCaptureFixture +): + """A successful check is reused for the same package without repeating warnings.""" + reflex_json, request = version_check_env + request.return_value.json.return_value = {"info": {"version": latest_version}} + + with caplog.at_level(logging.WARNING, logger=prerequisites.__name__): + prerequisites.check_latest_package_version("reflex") + prerequisites.check_latest_package_version("reflex") + + request.assert_called_once_with("https://pypi.org/pypi/reflex/json", timeout=2) + data = json.loads(reflex_json.read_text()) + assert data["last_version_check_datetime"] + assert data["project_hash"] == "test-project" + warnings = [ + record for record in caplog.records if record.levelno == logging.WARNING + ] + assert len(warnings) == (latest_version == "2.0.0") + + +def test_check_latest_package_version_disabled( + version_check_env, monkeypatch: pytest.MonkeyPatch +): + """Disabling version checks avoids requests and cache updates.""" + reflex_json, request = version_check_env + monkeypatch.setenv(environment.REFLEX_CHECK_LATEST_VERSION.name, "False") + contents = reflex_json.read_text() + + prerequisites.check_latest_package_version("reflex") + + request.assert_not_called() + assert reflex_json.read_text() == contents + + def test_prerequisites_does_not_import_database_stack() -> None: """Importing general prerequisites must not load optional database support.""" script = """ @@ -347,6 +517,8 @@ def _patch_frontend_package_manager( # inspect the install args without mocking subprocess primitives. def _stub_initial_install(primary_pm, env, frozen_lockfile): args = [primary_pm, "install", "--legacy-peer-deps"] + if not js_runtimes._is_bun_package_manager(primary_pm): + args.append("--include=dev") if frozen_lockfile and js_runtimes._is_bun_package_manager(primary_pm): args.append("--frozen-lockfile") run_package_manager( @@ -383,6 +555,43 @@ def _stub_framework_packages(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {}) +@pytest.mark.parametrize("config_class", ["Config", "AppConfig"]) +def test_frontend_package_cache_fingerprint_is_stable_across_processes( + tmp_path: Path, config_class: str +): + """Python hash randomization must not invalidate unchanged dependency installs.""" + script = f""" +import hashlib +from reflex_base.config import Config +from reflex.utils.js_runtimes import _frontend_packages_cache_payload + +class AppConfig(Config): + pass + +config = {config_class}( + app_name="test_app", + api_url="https://api.example.com", + deploy_url="https://example.com", + frontend_port=3001, + frozen_lockfile=True, + _skip_plugins_checks=True, +) +payload = _frontend_packages_cache_payload({{"some-package@1.0.0"}}, set(), config.frozen_lockfile, ("npm",)) +print(hashlib.sha256(payload.encode()).hexdigest()) +""" + fingerprints = { + subprocess.check_output( + [sys.executable, "-c", script], + cwd=tmp_path, + env={**os.environ, "PYTHONHASHSEED": seed}, + text=True, + ).strip() + for seed in ("1", "2", "3") + } + + assert len(fingerprints) == 1 + + @pytest.fixture def install_packages_env( tmp_path, monkeypatch @@ -1188,6 +1397,232 @@ def test_install_frontend_packages_pins_framework_dependencies( assert "--only-missing" not in pin_dev_deps_call +@pytest.mark.parametrize("package_manager", ["bun", "npm"]) +@pytest.mark.parametrize("has_lockfile", [False, True]) +def test_install_frontend_packages_reuses_installed_framework_pins( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + package_manager: str, + has_lockfile: bool, +): + """Matching framework pins need no add only after a successful initial install.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + env.root_package_json.write_text( + json.dumps({ + "dependencies": {"react": "19.2.5"}, + "devDependencies": {"vite": "8.0.9"}, + }) + ) + if has_lockfile: + lockfile = ( + env.root_lock + if package_manager == "bun" + else env.root_lock.parent / constants.Node.LOCKFILE_PATH + ) + lockfile.write_text("persisted-lock") + calls = _record_calls_with_pm(env, package_manager) + + env.install() + + install_calls = [call for call in calls if "install" in call] + assert len(install_calls) == int(has_lockfile) + if has_lockfile: + assert ("--frozen-lockfile" in install_calls[0]) == (package_manager == "bun") + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == (0 if has_lockfile else 2) + if not has_lockfile: + assert "vite@8.0.9" in add_calls[0] + assert "react@19.2.5" in add_calls[1] + + +def test_install_frontend_packages_updates_only_changed_framework_pins( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Changed, absent, or misplaced framework pins still get installed.""" + env = install_packages_env + monkeypatch.setattr( + constants.PackageJson, + "DEPENDENCIES", + { + "react": "19.2.5", + "react-dom": "19.2.5", + "isbot": "5.2.2", + }, + ) + monkeypatch.setattr( + constants.PackageJson, + "DEV_DEPENDENCIES", + { + "vite": "8.0.9", + "postcss": "8.5.26", + }, + ) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": { + "react": "19.2.5", + "react-dom": "18.0.0", + "stale": "1.0.0", + }, + "devDependencies": {"isbot": "5.2.2", "vite": "8.0.9"}, + }) + ) + calls = _record_calls(env) + + env.install() + + assert set(calls[0][3:]) == {"stale", "isbot"} + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert "postcss@8.5.26" in add_calls[0] + assert {"react-dom@19.2.5", "isbot@5.2.2"}.issubset(add_calls[1]) + assert all( + "react@19.2.5" not in call and "vite@8.0.9" not in call for call in add_calls + ) + + +def test_install_frontend_packages_keeps_custom_and_plugin_pin_resolution( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Custom pins still resolve and dev requests cannot override framework deps.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + + class FakePlugin: + def get_frontend_dependencies(self): + return {"plugin-pkg@1.0.0"} + + def get_frontend_development_dependencies(self): + return {"plugin-dev@2.0.0", "react@18.0.0"} + + monkeypatch.setattr(env.config, "plugins", [FakePlugin()]) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": { + "react": "19.2.5", + "plugin-pkg": "1.0.0", + "custom": "^3.0.0", + }, + "devDependencies": {"plugin-dev": "2.0.0"}, + }) + ) + calls = _record_calls(env) + + env.install({"custom@^3.0.0"}) + + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert "plugin-dev@2.0.0" in add_calls[0] + assert {"plugin-pkg@1.0.0", "custom@^3.0.0"}.issubset(add_calls[1]) + assert all(not any(arg.startswith("react@") for arg in call) for call in add_calls) + + +def test_install_frontend_packages_reconciles_overrides_with_matching_framework_pins( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Skipping matched pins must still apply newly merged framework overrides.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {"postcss": "8.5.26"}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text(json.dumps({"dependencies": {"react": "19.2.5"}})) + calls = _record_calls(env) + + env.install() + + assert len(calls) == 2 + assert "install" in calls[0] + assert "--frozen-lockfile" in calls[0] + assert "install" in calls[1] + assert "--frozen-lockfile" not in calls[1] + assert json.loads(env.root_package_json.read_text())["overrides"] == { + "postcss": "8.5.26" + } + + +def test_install_frontend_packages_preserves_conflicting_pin_arguments( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Explicit pins conflicting with framework pins keep the original add arguments.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + + class FakePlugin: + def get_frontend_dependencies(self): + return set() + + def get_frontend_development_dependencies(self): + return {"vite@7.0.0"} + + monkeypatch.setattr(env.config, "plugins", [FakePlugin()]) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({ + "dependencies": {"react": "19.2.5"}, + "devDependencies": {"vite": "8.0.9"}, + }) + ) + calls = _record_calls(env) + + env.install({"react@18.0.0"}) + + add_calls = [call for call in calls if "add" in call] + assert len(add_calls) == 2 + assert {"vite@7.0.0", "vite@8.0.9"}.issubset(add_calls[0]) + assert {"react@18.0.0", "react@19.2.5"}.issubset(add_calls[1]) + + +def test_install_frontend_packages_matching_pins_do_not_bypass_failed_install( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """A failed frozen install propagates and never marks the dependencies cached.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text(json.dumps({"dependencies": {"react": "19.2.5"}})) + calls = _record_calls(env) + monkeypatch.setattr( + js_runtimes, "_run_initial_install", Mock(side_effect=SystemExit(1)) + ) + + with pytest.raises(SystemExit): + env.install() + + assert calls == [] + assert not js_runtimes._frontend_packages_cache_path().exists() + + +@pytest.mark.parametrize( + "version_spec", + ["^19.2.5", "19.x", "latest", "github:facebook/react", "file:../react"], +) +def test_install_frontend_packages_still_resolves_framework_version_overrides( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + version_spec: str, +): + """Framework version overrides that are not exact pins keep their add behavior.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": version_spec}) + env.root_lock.write_text("persisted-lock") + env.root_package_json.write_text( + json.dumps({"dependencies": {"react": version_spec}}) + ) + calls = _record_calls(env) + + env.install() + + assert len(calls) == 2 + assert "install" in calls[0] + assert "add" in calls[1] + assert f"react@{version_spec}" in calls[1] + + def _record_calls_with_pm( env: InstallPackagesEnv, package_manager: str ) -> list[list[str]]: @@ -1209,6 +1644,231 @@ def run_package_manager(args, **kwargs): return calls +@pytest.mark.parametrize("package_manager", ["bun", "npm"]) +def test_install_frontend_packages_uses_package_manager_dev_flag( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + package_manager: str, +): + """Npm must save development tools in devDependencies, including in production.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + monkeypatch.setenv("NODE_ENV", "production") + calls = _record_calls_with_pm(env, package_manager) + + env.install() + + assert len(calls) == 1 + assert ("--save-dev" in calls[0]) == (package_manager == "npm") + assert ("-d" in calls[0]) == (package_manager == "bun") + assert ("--include=dev" in calls[0]) == (package_manager == "npm") + + +@pytest.mark.parametrize("lockfile_version", [2, 3]) +def test_install_frontend_packages_reuses_verified_npm_caret_pins( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + lockfile_version: int, +): + """Npm's saved caret declarations can reuse the exact version just installed.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr( + constants.PackageJson, "DEV_DEPENDENCIES", {"@scope/tool": "2.0.0"} + ) + package_json = { + "dependencies": {"react": "^19.2.5"}, + "devDependencies": {"@scope/tool": "^2.0.0"}, + } + env.root_package_json.write_text(json.dumps(package_json)) + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text( + json.dumps({ + "lockfileVersion": lockfile_version, + "packages": { + "": package_json, + "node_modules/react": {"version": "19.2.5"}, + "node_modules/@scope/tool": {"version": "2.0.0", "name": "@scope/tool"}, + }, + }) + ) + for name, package_version in (("react", "19.2.5"), ("@scope/tool", "2.0.0")): + package_dir = env.web_dir / "node_modules" / name + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps({"name": name, "version": package_version}) + ) + calls = _record_calls_with_pm(env, "npm") + + env.install() + + assert len(calls) == 1 + assert "install" in calls[0] + + npm_lock = env.root_lock.parent / constants.Node.LOCKFILE_PATH + saved_package_json = env.root_package_json.read_bytes() + saved_lock = npm_lock.read_bytes() + js_runtimes._frontend_packages_cache_path().unlink() + calls.clear() + env.install() + + assert len(calls) == 1 + assert "install" in calls[0] + assert env.root_package_json.read_bytes() == saved_package_json + assert npm_lock.read_bytes() == saved_lock + + +@pytest.mark.parametrize( + "case", + [ + "newer_version", + "wrong_section", + "alias", + "link", + "local_tarball", + "missing_entry", + "legacy", + "malformed", + "malformed_packages", + "missing_root", + "missing_manifest", + "newer_installed_version", + "aliased_manifest", + "linked_package", + "explicit_conflict", + "tilde", + "overrides_changed", + ], +) +def test_install_frontend_packages_npm_caret_pin_fallback( + install_packages_env: InstallPackagesEnv, + monkeypatch: pytest.MonkeyPatch, + case: str, +): + """Unverified npm declarations keep the explicit framework add operation.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + declaration = "~19.2.5" if case == "tilde" else "^19.2.5" + package_json = {"dependencies": {"react": declaration}} + env.root_package_json.write_text(json.dumps(package_json)) + locked_react: dict[str, object] = {"version": "19.2.5"} + lock = { + "lockfileVersion": 3, + "packages": {"": package_json, "node_modules/react": locked_react}, + } + if case == "newer_version": + locked_react["version"] = "19.3.0" + elif case == "wrong_section": + lock["packages"][""] = {"devDependencies": {"react": declaration}} + elif case == "alias": + locked_react["name"] = "different-package" + elif case == "link": + locked_react["link"] = True + elif case == "local_tarball": + locked_react["resolved"] = "file:../react.tgz" + elif case == "missing_entry": + del lock["packages"]["node_modules/react"] + elif case == "legacy": + lock["lockfileVersion"] = 1 + elif case == "malformed_packages": + lock["packages"] = [] + elif case == "missing_root": + del lock["packages"][""] + elif case == "overrides_changed": + monkeypatch.setattr(constants.PackageJson, "OVERRIDES", {"postcss": "8.5.26"}) + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text( + "invalid" if case == "malformed" else json.dumps(lock) + ) + package_dir = env.web_dir / "node_modules" / "react" + package_dir.mkdir(parents=True) + if case != "missing_manifest": + (package_dir / "package.json").write_text( + json.dumps({ + "name": "different-package" if case == "aliased_manifest" else "react", + "version": "19.3.0" if case == "newer_installed_version" else "19.2.5", + }) + ) + if case == "linked_package": + if constants.IS_WINDOWS: + pytest.skip("Requires directory symlinks") + linked_package = env.tmp_path / "linked-package" + package_dir.rename(linked_package) + package_dir.symlink_to(linked_package, target_is_directory=True) + calls = _record_calls_with_pm(env, "npm") + + env.install({"react@18.0.0"} if case == "explicit_conflict" else None) + + assert len(calls) == 2 + assert "install" in calls[0] + assert "add" in calls[1] + assert "react@19.2.5" in calls[1] + if case == "explicit_conflict": + assert "react@18.0.0" in calls[1] + + +def test_install_frontend_packages_reads_npm_versions_after_install( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """A newer version resolved during initial install must be repinned afterwards.""" + env = install_packages_env + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + package_json = {"dependencies": {"react": "^19.2.5"}} + env.root_package_json.write_text(json.dumps(package_json)) + lock = { + "lockfileVersion": 3, + "packages": {"": package_json, "node_modules/react": {"version": "19.2.5"}}, + } + (env.root_lock.parent / constants.Node.LOCKFILE_PATH).write_text(json.dumps(lock)) + package_dir = env.web_dir / "node_modules" / "react" + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text( + json.dumps({"name": "react", "version": "19.2.5"}) + ) + calls: list[list[str]] = [] + + def run_package_manager(args, **kwargs): + calls.append(list(args)) + if "install" in args: + lock["packages"]["node_modules/react"]["version"] = "19.3.0" + (env.web_dir / constants.Node.LOCKFILE_PATH).write_text(json.dumps(lock)) + + env.patch_pm(["npm"], run_package_manager) + env.install() + + assert len(calls) == 2 + assert "react@19.2.5" in calls[1] + + +def test_install_frontend_packages_all_npm_operations_include_dev( + install_packages_env: InstallPackagesEnv, monkeypatch: pytest.MonkeyPatch +): + """Later npm add/remove operations must not prune development tools in production.""" + env = install_packages_env + monkeypatch.setenv("NODE_ENV", "production") + monkeypatch.setattr(constants.PackageJson, "DEPENDENCIES", {"react": "19.2.5"}) + monkeypatch.setattr(constants.PackageJson, "DEV_DEPENDENCIES", {"vite": "8.0.9"}) + env.root_package_json.write_text(json.dumps({"dependencies": {"stale": "1.0.0"}})) + calls = _record_calls_with_pm(env, "npm") + + env.install() + + assert {call[1] for call in calls} == {"remove", "add"} + assert all("--include=dev" in call for call in calls) + + +def test_run_initial_npm_install_includes_dev_in_production( + monkeypatch: pytest.MonkeyPatch, +): + """Installing a restored npm lock must include development tools for the build.""" + monkeypatch.setenv("NODE_ENV", "production") + new_process = Mock(return_value=Mock(returncode=0)) + monkeypatch.setattr(js_runtimes.processes, "new_process", new_process) + monkeypatch.setattr(js_runtimes.processes, "show_status", Mock(return_value=[])) + + js_runtimes._run_initial_install("npm", {}, frozen_lockfile=True) + + assert "--include=dev" in new_process.call_args.args[0] + + def test_install_frontend_packages_npm_skips_frozen_lockfile( install_packages_env: InstallPackagesEnv, ): diff --git a/tests/units/test_reflex.py b/tests/units/test_reflex.py index 1e76ad1a2a1..de11ed4cda1 100644 --- a/tests/units/test_reflex.py +++ b/tests/units/test_reflex.py @@ -10,6 +10,8 @@ import click import click.testing import pytest +from pytest_mock import MockerFixture +from reflex_base import constants from reflex import reflex @@ -431,3 +433,111 @@ def test_init_records_version_check_after_frontend_setup( reflex._init("demo") assert events == ["frontend", "version"] + + +@pytest.fixture +def patched_production_startup(mocker: MockerFixture) -> dict: + """Patch process startup while retaining the production command sequence. + + Returns: + Mocked startup operations and the frontend lock context. + """ + config = mocker.patch("reflex.reflex.get_config", return_value=mocker.Mock()) + mocker.patch("reflex.reflex._skip_compile") + mocker.patch("atexit.register") + mocker.patch("reflex.utils.telemetry.send") + mocker.patch("reflex.utils.exec.notify_app_running") + mocker.patch("reflex.utils.exec.notify_frontend") + mount_frontend = mocker.patch( + "reflex.reflex.environment.REFLEX_MOUNT_FRONTEND_COMPILED_APP" + ).set + return { + "config": config.return_value, + "mount_frontend": mount_frontend, + "lock": mocker.patch("reflex.utils.build_cache.frontend_build_lock"), + "compile": mocker.patch("reflex.reflex._compile_app"), + "setup": mocker.patch("reflex.utils.build.setup_frontend_prod"), + "backend_prod": mocker.patch("reflex.utils.exec.run_backend_prod"), + "backend_preview": mocker.patch("reflex.utils.exec.run_backend"), + "frontend": mocker.patch("reflex.utils.exec.run_frontend_prod"), + } + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize( + "running_mode", + [constants.RunningMode.FULLSTACK, constants.RunningMode.FRONTEND_ONLY], +) +def test_production_startup_locks_compile_and_build_before_serving( + patched_production_startup, runner_name: str, running_mode: constants.RunningMode +): + """Only compile and build hold the lock; serving starts after release.""" + context = patched_production_startup["lock"].return_value + + def require_lock(*args, **kwargs): + """Compilation and setup share the same unreleased lock.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + def require_release(*args, **kwargs): + """Serving must not retain the frontend lock for the server lifetime.""" + context.__exit__.assert_called_once_with(None, None, None) + + patched_production_startup["compile"].side_effect = require_lock + patched_production_startup["setup"].side_effect = require_lock + patched_production_startup["config"]._set_persistent.side_effect = require_lock + patched_production_startup["mount_frontend"].side_effect = require_lock + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].side_effect = require_release + + getattr(reflex, runner_name)(running_mode, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_called_once() + patched_production_startup["setup"].assert_called_once() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + server = ( + "frontend" + if running_mode == constants.RunningMode.FRONTEND_ONLY + else "backend_prod" + if runner_name == "_run_prod" + else "backend_preview" + ) + patched_production_startup[server].assert_called_once() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +@pytest.mark.parametrize("failing_target", ["compile", "setup"]) +def test_production_startup_releases_lock_after_failed_build( + patched_production_startup, runner_name: str, failing_target: str +): + """A startup failure releases the lock and does not start a server.""" + error = RuntimeError("build failed") + patched_production_startup[failing_target].side_effect = error + with pytest.raises(RuntimeError, match="build failed"): + getattr(reflex, runner_name)(constants.RunningMode.FULLSTACK, 8000, "127.0.0.1") + + context = patched_production_startup["lock"].return_value + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) + for server in ("backend_prod", "backend_preview", "frontend"): + patched_production_startup[server].assert_not_called() + + +@pytest.mark.parametrize("runner_name", ["_run_prod", "_run_preview"]) +def test_backend_only_startup_does_not_lock_frontend( + patched_production_startup, runner_name: str +): + """Backend-only startup does not access the frontend working directory.""" + getattr(reflex, runner_name)(constants.RunningMode.BACKEND_ONLY, 8000, "127.0.0.1") + + patched_production_startup["lock"].assert_not_called() + patched_production_startup["compile"].assert_not_called() + patched_production_startup["setup"].assert_not_called() + patched_production_startup["config"]._set_persistent.assert_called_once_with( + frontend_port=8000, backend_port=8000 + ) + if runner_name == "_run_preview": + patched_production_startup["mount_frontend"].assert_called_once_with(False) diff --git a/tests/units/utils/test_build.py b/tests/units/utils/test_build.py index 750d8890275..9128d931ce5 100644 --- a/tests/units/utils/test_build.py +++ b/tests/units/utils/test_build.py @@ -4,16 +4,99 @@ import gzip import json +import zipfile from pathlib import Path import pytest import reflex_base from pytest_mock import MockerFixture +from reflex_base import constants from reflex.plugins import EmbedPlugin, Plugin from reflex.utils import build, path_ops +@pytest.mark.parametrize("suffix", [".gz", ".br", ".zst"]) +@pytest.mark.parametrize("component_name", list(constants.ComponentName)) +def test_zip_precompressed_sidecars( + tmp_path: Path, suffix: str, component_name: constants.ComponentName +): + """Skip recompression for frontend sidecars and preserve backend compression.""" + static_dir = tmp_path / "static" + static_dir.mkdir() + source = b"export const greeting = 'hello';\n" * 100 + sidecar = gzip.compress(source) + (static_dir / "app.js").write_bytes(source) + (static_dir / f"app.js{suffix}").write_bytes(sidecar) + target = tmp_path / "frontend.zip" + + build._zip( + component_name=component_name, + target=target, + root_directory=static_dir, + exclude_venv_directories=False, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.read("app.js") == source + assert archive.read(f"app.js{suffix}") == sidecar + assert archive.getinfo("app.js").compress_type == zipfile.ZIP_DEFLATED + assert archive.getinfo(f"app.js{suffix}").compress_type == ( + zipfile.ZIP_STORED + if component_name == constants.ComponentName.FRONTEND + else zipfile.ZIP_DEFLATED + ) + + +def test_zip_excludes_files_and_hardlink_aliases(tmp_path: Path): + """Archive exclusions must match file identity, including hard-linked aliases.""" + root = tmp_path / "app" + root.mkdir() + (root / "app.py").write_text("print('app')") + excluded_file = root / "secret.txt" + excluded_file.write_text("secret") + (root / "alias.txt").hardlink_to(excluded_file) + excluded_dir = root / "excluded" + excluded_dir.mkdir() + (excluded_dir / "private.txt").write_text("private") + target = tmp_path / "backend.zip" + + build._zip( + component_name=constants.ComponentName.BACKEND, + target=target, + root_directory=root, + exclude_venv_directories=True, + files_to_exclude={excluded_file, excluded_dir, root / "missing"}, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.namelist() == ["app.py"] + assert archive.read("app.py") == b"print('app')" + + +@pytest.mark.skipif(constants.IS_WINDOWS, reason="Requires directory symlinks") +def test_zip_excludes_directory_symlink_aliases(tmp_path: Path): + """An excluded directory must also be skipped through a symbolic link.""" + root = tmp_path / "app" + root.mkdir() + excluded_dir = root / "excluded" + excluded_dir.mkdir() + (excluded_dir / "private.txt").write_text("private") + (root / "alias").symlink_to(excluded_dir, target_is_directory=True) + target = tmp_path / "backend.zip" + + build._zip( + component_name=constants.ComponentName.BACKEND, + target=target, + root_directory=root, + exclude_venv_directories=True, + files_to_exclude={excluded_dir}, + ) + + with zipfile.ZipFile(target) as archive: + assert archive.namelist() == [] + + def test_compress_static_output_overwrites_stale_sidecars( tmp_path: Path, mocker: MockerFixture ): diff --git a/tests/units/utils/test_build_cache.py b/tests/units/utils/test_build_cache.py new file mode 100644 index 00000000000..804abdfda97 --- /dev/null +++ b/tests/units/utils/test_build_cache.py @@ -0,0 +1,686 @@ +"""Regression coverage for opt-in reuse of production frontend builds.""" + +from __future__ import annotations + +import errno +import json +import multiprocessing +import os +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pytest_mock import MockerFixture + +from reflex.plugins import Plugin +from reflex.utils import build, build_cache +from reflex.utils import export as export_utils + + +def _acquire_spawned_build_lock(web_dir, attempted, entered): + """Acquire a workspace lock from an independent interpreter. + + Args: + web_dir: Shared workspace path. + attempted: Event set before attempting acquisition. + entered: Event set after acquisition. + """ + attempted.set() + with build_cache.frontend_build_lock(web_dir): + entered.set() + + +@pytest.mark.parametrize("cache_enabled", ["true", "false"]) +def test_spawned_build_lock_serializes_with_or_without_cache( + tmp_path, monkeypatch, cache_enabled +): + """Independent processes serialize on every platform, even without caching.""" + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", cache_enabled) + context = multiprocessing.get_context("spawn") + attempted, entered = context.Event(), context.Event() + child = context.Process( + target=_acquire_spawned_build_lock, args=(tmp_path, attempted, entered) + ) + try: + with ( + build_cache.frontend_build_lock(tmp_path), + build_cache.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(15) + assert not entered.wait(0.3) + assert entered.wait(15) + finally: + if child.pid is not None: + child.join(15) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +def test_windows_lock_waits_for_contention_and_releases(tmp_path, monkeypatch, mocker): + """Windows retries contention beyond ten attempts and releases after errors.""" + locking = mocker.Mock( + side_effect=[*[OSError(errno.EACCES, "busy")] * 11, None, None] + ) + sleep = mocker.patch("time.sleep") + with ( + monkeypatch.context() as patch, + pytest.raises(RuntimeError, match="build failed"), + ): + patch.setattr(build_cache.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_cache.frontend_build_lock(tmp_path): + assert locking.call_count == 12 + message = "build failed" + raise RuntimeError(message) + assert locking.call_count == 13 + assert locking.call_args.args[1:] == (0, 1) + assert sleep.call_count == 11 + + +def test_windows_lock_failure_stops_workspace_access(tmp_path, monkeypatch, mocker): + """Unexpected Windows lock failures propagate without entering the workspace.""" + locking = mocker.Mock(side_effect=OSError(errno.EBADF, "invalid descriptor")) + with ( + monkeypatch.context() as patch, + pytest.raises(OSError, match="invalid descriptor"), + ): + patch.setattr(build_cache.sys, "platform", "win32") + patch.setitem( + sys.modules, + "msvcrt", + SimpleNamespace(locking=locking, LK_NBLCK=2, LK_UNLCK=0), + ) + with build_cache.frontend_build_lock(tmp_path): + pytest.fail("Entered workspace without its lock") + + +@pytest.fixture +def cached_build( + tmp_path: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch +): + """Create a frontend whose simulated build reads real source files. + + Returns: + The frontend directory, mutable config, and build subprocess mock. + """ + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "true") + web = tmp_path / ".web" + (web / "app").mkdir(parents=True) + (web / "app/page.js").write_text("original") + (web / "public").mkdir() + (web / "public/style.css").write_text("body{color:red}") + (web / "node_modules/package").mkdir(parents=True) + (web / "node_modules/package/index.js").write_text("old") + (web / "package.json").write_text('{"scripts":{"export":"react-router build"}}') + (web / "bun.lock").write_text("lock") + (web / "env.json").write_text('{"EVENT":"https://first.example/_event"}') + (web / "reflex.json").write_text('{"version":"1.0.0","project_hash":42}') + runtime = tmp_path / "runtime" + runtime.write_text("runtime") + config = mocker.Mock() + config.plugins = [] + config.frontend_compression_formats = ["gzip"] + config.frontend_path = "" + mocker.patch.object(build.prerequisites, "get_web_dir", return_value=web) + mocker.patch.object(build, "get_config", return_value=config) + mocker.patch.object(build.path_ops, "get_node_path", return_value=str(runtime)) + mocker.patch.object( + build.js_runtimes, + "get_js_package_executor", + return_value=([str(runtime)], None), + ) + + def compile_frontend(*args, **kwargs): + output = web / "build/client" + output.mkdir(parents=True) + if config.frontend_path: + (output / config.frontend_path.strip("/")).mkdir(parents=True) + (output / "index.html").write_text((web / "app/page.js").read_text()) + (output / "bundle.js").write_text("compiled") + return mocker.Mock(returncode=0) + + process = mocker.patch.object( + build.processes, "new_process", side_effect=compile_frontend + ) + mocker.patch.object(build.processes, "show_progress") + mocker.patch.object(build, "_compress_static_output") + return web, config, process + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_unchanged_build_reuses_pristine_output(cached_build, mocker: MockerFixture): + """Reuse Vite output while executing post-build work on every invocation.""" + web, config, process = cached_build + compress = mocker.patch.object(build, "_compress_static_output") + calls = [] + + class AppendPlugin(Plugin): + def post_build(self, **context): + calls.append(len(calls) + 1) + index = context["static_dir"] / "index.html" + index.write_text(index.read_text() + f" hook{calls[-1]}") + + config.plugins = [AppendPlugin()] + config.frontend_path = "/site" + build.build() + build.build() + assert process.call_count == 1 + assert calls == [1, 2] + assert (web / "build/client/site/index.html").read_text() == "original hook2" + assert not (web / "build/client/site/site").exists() + assert compress.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_telemetry_timestamps_do_not_invalidate_build(cached_build): + """Private run and per-package version timestamps do not affect build output.""" + web, _, process = cached_build + build.build() + metadata = web / "reflex.json" + data = json.loads(metadata.read_text()) + data.update( + last_reflex_run_datetime="later", + last_version_check_datetime="later", + last_version_check_attempt_datetime="later", + last_version_check_datetime_reflex_hosting_cli="later", + last_version_check_attempt_datetime_reflex_hosting_cli="later", + ) + metadata.write_text(json.dumps(data)) + build.build() + assert process.call_count == 1 + data["version"] = "2.0.0" + metadata.write_text(json.dumps(data)) + build.build() + assert process.call_count == 2 + + +@pytest.mark.parametrize( + "name", ["app/page.js", "public/style.css", "env.json", "bun.lock", "package.json"] +) +def test_changed_build_inputs_rebuild(cached_build, name: str): + """Changes to code, assets, URLs, locks, or package scripts invalidate output.""" + web, _, process = cached_build + build.build() + target = web / name + target.write_text(target.read_text() + " ") + build.build() + assert process.call_count == 2 + + +def test_dependency_edit_with_restored_mtime_rebuilds(cached_build): + """POSIX ctime detects same-length dependency edits even if mtime is restored.""" + web, _, process = cached_build + build.build() + dependency = web / "node_modules/package/index.js" + before = dependency.stat() + dependency.write_text("new") + os.utime(dependency, ns=(before.st_atime_ns, before.st_mtime_ns)) + build.build() + assert process.call_count == 2 + + +def test_disabled_cache_forces_and_refreshes_build(cached_build, monkeypatch): + """Disabling the cache prevents an older snapshot from being reused later.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "false") + build.build() + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "true") + build.build() + assert process.call_count == 3 + + +def test_build_environment_change_rebuilds(cached_build, monkeypatch): + """A Vite environment value invalidates the build cache.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("VITE_CUSTOM_BUILD_VALUE", "new") + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif( + os.name == "nt", reason="Frontend build cache is disabled on Windows" +) +def test_non_build_environment_change_reuses_cache(cached_build, monkeypatch): + """Unrelated shell state does not invalidate a deterministic frontend build.""" + _, _, process = cached_build + build.build() + monkeypatch.setenv("SHLVL", "999") + build.build() + assert process.call_count == 1 + + +def test_failed_build_is_retried(cached_build): + """A failed Vite run must never populate the cache.""" + _, _, process = cached_build + compile_frontend = process.side_effect + process.side_effect = None + process.return_value.returncode = 1 + with pytest.raises(SystemExit): + build.build() + process.side_effect = compile_frontend + build.build() + assert process.call_count == 2 + + +def test_failed_post_build_is_retried(cached_build): + """Post-build failure must not publish partially processed output.""" + _, config, process = cached_build + + class FailingPlugin(Plugin): + def post_build(self, **context): + msg = "plugin failed" + raise RuntimeError(msg) + + config.plugins = [FailingPlugin()] + with pytest.raises(RuntimeError, match="plugin failed"): + build.build() + config.plugins = [] + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_generated_dependency_caches_do_not_invalidate(cached_build): + """Vite's transient dependency caches are excluded from tracked inputs.""" + web, _, process = cached_build + build.build() + for name in (".vite", ".vite-temp"): + (web / "node_modules" / name).mkdir() + (web / "node_modules" / name / "temporary.js").write_text("temporary") + build.build() + assert process.call_count == 1 + + +@pytest.mark.parametrize( + "mutation", ["missing", "content", "extra", "metadata", "mode"] +) +def test_damaged_snapshot_rebuilds(cached_build, mutation: str): + """A missing, modified, or malformed snapshot cannot bypass a fresh build.""" + web, _, process = cached_build + build.build() + current = web / "reflex.build-cache/current" + if os.name == "nt": + assert not current.exists() + return + bundle = current / "build/client/bundle.js" + if mutation == "missing": + bundle.unlink() + elif mutation == "content": + bundle.write_text("tampered") + elif mutation == "extra": + (current / "build/client/extra.js").write_text("unexpected") + elif mutation == "metadata": + (current / "metadata.json").write_text("[]") + else: + bundle.chmod(0o600) + build.build() + assert process.call_count == 2 + assert (web / "build/client/bundle.js").read_text() == "compiled" + + +def test_input_changed_during_build_is_not_cached(cached_build): + """An input change during Vite cannot label old output with a reusable key.""" + web, _, process = cached_build + compile_frontend = process.side_effect + + def changing_build(*args, **kwargs): + result = compile_frontend(*args, **kwargs) + (web / "app/page.js").write_text("changed during build") + return result + + process.side_effect = changing_build + build.build() + assert not (web / "reflex.build-cache/current").exists() + process.side_effect = compile_frontend + build.build() + assert process.call_count == 2 + assert (web / "build/client/index.html").read_text() == "changed during build" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +@pytest.mark.parametrize("directory", ["app", "node_modules"]) +def test_external_symlink_bypasses_cache(cached_build, tmp_path: Path, directory: str): + """Untracked external source or dependency targets force normal builds.""" + web, _, process = cached_build + external = tmp_path / "external.js" + external.write_text("external") + (web / directory / "external.js").symlink_to(external) + build.build() + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_external_redirect_between_internal_sources_rebuilds(cached_build, tmp_path): + """Resolve intermediate links again before reusing their internal target's output.""" + web, _, process = cached_build + first = web / "app/first.js" + second = web / "app/second.js" + first.write_text("first") + second.write_text("second") + redirect = tmp_path / "redirect.js" + redirect.symlink_to(first) + source = web / "app/page.js" + source.unlink() + source.symlink_to(redirect) + + build.build() + build.build() + assert process.call_count == 1 + assert (web / "build/client/index.html").read_text() == "first" + + redirect.unlink() + redirect.symlink_to(second) + build.build() + assert process.call_count == 2 + assert (web / "build/client/index.html").read_text() == "second" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_internal_dependency_symlink_is_tracked(cached_build): + """Normal package executable links remain cacheable and their targets are tracked.""" + web, _, process = cached_build + binaries = web / "node_modules/.bin" + binaries.mkdir() + (binaries / "package").symlink_to("../package/index.js") + build.build() + build.build() + assert process.call_count == 1 + (web / "node_modules/package/index.js").write_text("new") + build.build() + assert process.call_count == 2 + + +@pytest.mark.parametrize("mutation", ["add", "remove"]) +def test_source_file_membership_invalidates(cached_build, mutation: str): + """Added and removed source files both change the input fingerprint.""" + web, _, process = cached_build + extra = web / "app/extra.js" + if mutation == "remove": + extra.write_text("extra") + build.build() + if mutation == "add": + extra.write_text("extra") + else: + extra.unlink() + build.build() + assert process.call_count == 2 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache uses POSIX change timestamps") +def test_deleted_processed_output_restores_snapshot(cached_build): + """The pristine snapshot can restore output deleted after a successful build.""" + web, _, process = cached_build + build.build() + build.path_ops.rm(web / "build") + build.build() + assert process.call_count == 1 + assert (web / "build/client/index.html").read_text() == "original" + + +@pytest.mark.skipif(os.name == "nt", reason="Requires symlinks") +def test_link_into_ignored_dependency_cache_bypasses(cached_build): + """A link cannot turn an excluded transient file into an untracked input.""" + web, _, process = cached_build + generated = web / "node_modules/.cache" + generated.mkdir() + (generated / "style.css").write_text("old") + (web / "public/linked.css").symlink_to("../node_modules/.cache/style.css") + build.build() + (generated / "style.css").write_text("new") + build.build() + assert process.call_count == 2 + assert not (web / "reflex.build-cache/current").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Requires POSIX symlinks and modes") +@pytest.mark.parametrize("entry", ["directory", "current"]) +def test_cache_symlink_never_changes_external_target( + cached_build, tmp_path, monkeypatch, entry +): + """Discard cache links without chmod or removal of their external targets.""" + web, _, _ = cached_build + external = tmp_path / "external-cache" + external.mkdir(mode=0o700) + (external / "keep").write_text("keep") + previous_mode = external.stat().st_mode + directory = web / "reflex.build-cache" + if entry == "directory": + directory.symlink_to(external, target_is_directory=True) + monkeypatch.setenv("REFLEX_FRONTEND_BUILD_CACHE", "false") + else: + directory.mkdir() + (directory / "current").symlink_to(external, target_is_directory=True) + build.build() + assert external.stat().st_mode == previous_mode + assert (external / "keep").read_text() == "keep" + + +@pytest.mark.skipif(os.name == "nt", reason="Cache and flock require POSIX") +@pytest.mark.parametrize("cache_modes", [(True, True), (True, False), (False, True)]) +def test_overlapping_builds_serialize(cached_build, cache_modes, mocker): + """Another process cannot replace Vite output before capture and publication.""" + web, _, process = cached_build + context = multiprocessing.get_context("fork") + first_ready = context.Event() + release_first = context.Event() + second_attempted = context.Event() + second_built = context.Event() + executor = build.js_runtimes.get_js_package_executor(raise_on_none=True)[0] + package_executor = mocker.patch.object( + build.js_runtimes, "get_js_package_executor", return_value=(executor, None) + ) + compile_frontend = process.side_effect + + def run_build(label, enabled): + os.environ["REFLEX_FRONTEND_BUILD_CACHE"] = str(enabled).lower() + package_executor.return_value = ( + [*executor, label], + None, + ) + + def compile_labeled_frontend(*args, **kwargs): + result = compile_frontend(*args, **kwargs) + (web / "build/client/index.html").write_text(label) + if label == "first": + first_ready.set() + assert release_first.wait(10) + else: + second_built.set() + return result + + process.side_effect = compile_labeled_frontend + if label == "second": + second_attempted.set() + build.build() + + first = context.Process(target=run_build, args=("first", cache_modes[0])) + second = context.Process(target=run_build, args=("second", cache_modes[1])) + first.start() + try: + assert first_ready.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_built.wait(0.3), "Second Vite replaced an active build" + finally: + release_first.set() + for worker in (first, second): + if worker.pid is not None: + worker.join(10) + if worker.is_alive(): + worker.terminate() + worker.join(5) + assert first.exitcode == second.exitcode == 0 + assert second_built.is_set() + assert (web / "build/client/index.html").read_text() == "second" + if cache_modes[1]: + package_executor.return_value = ( + [*executor, "second"], + None, + ) + build.build() + assert process.call_count == 0 + assert (web / "build/client/index.html").read_text() == "second" + else: + assert not (web / "reflex.build-cache/current").exists() + + +@pytest.mark.skipif(os.name == "nt", reason="Requires fork and flock") +def test_forked_child_does_not_inherit_lock_ownership(tmp_path): + """Nested calls are reentrant, but a forked child must wait for its parent.""" + context = multiprocessing.get_context("fork") + attempted = context.Event() + entered = context.Event() + + def acquire_in_child(): + attempted.set() + with build_cache.frontend_build_lock(tmp_path): + entered.set() + + child = context.Process(target=acquire_in_child) + try: + with ( + build_cache.frontend_build_lock(tmp_path), + build_cache.frontend_build_lock(tmp_path / "."), + ): + child.start() + assert attempted.wait(10) + assert not entered.wait(0.3) + assert entered.wait(10) + finally: + if child.pid is not None: + child.join(10) + if child.is_alive(): + child.terminate() + child.join(5) + assert child.exitcode == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="Cache and flock require POSIX") +@pytest.mark.parametrize("second_cache_enabled", [True, False]) +def test_overlapping_exports_preserve_inputs_through_zip( + cached_build, tmp_path, mocker, second_cache_enabled +): + """An export waiting to archive cannot lose its inputs/output to another export.""" + web, config, _ = cached_build + context = multiprocessing.get_context("fork") + first_zipping = context.Event() + release_zip = context.Event() + second_attempted = context.Event() + second_compiled = context.Event() + first_url = "https://first.example" + second_url = "https://second.example" + mocker.patch.object(export_utils, "get_config", return_value=config) + mocker.patch.object(export_utils.exec, "output_system_info") + mocker.patch.object(export_utils.telemetry, "send") + + def set_config(**values): + for key, value in values.items(): + setattr(config, key, value) + + config._set_persistent.side_effect = set_config + + def compile_app(**kwargs): + if config.api_url == second_url: + second_compiled.set() + (web / "app/page.js").write_text(config.api_url) + + def setup_frontend(root): + (web / "env.json").write_text(json.dumps({"API_URL": config.api_url})) + + def zip_app(**kwargs): + if config.api_url == first_url: + first_zipping.set() + assert release_zip.wait(10) + destination = Path(kwargs["zip_dest_dir"]) + destination.mkdir() + (destination / "frontend.txt").write_text( + (web / "build/client/index.html").read_text() + ) + + mocker.patch.object(export_utils.prerequisites, "get_compiled_app", compile_app) + mocker.patch.object(build, "setup_frontend", setup_frontend) + mocker.patch.object(build, "zip_app", zip_app) + + def run_export(name, url, cache_enabled): + os.environ["REFLEX_FRONTEND_BUILD_CACHE"] = str(cache_enabled).lower() + if name == "second": + second_attempted.set() + export_utils.export(api_url=url, zip_dest_dir=str(tmp_path / name)) + + first = context.Process(target=run_export, args=("first", first_url, True)) + second = context.Process( + target=run_export, args=("second", second_url, second_cache_enabled) + ) + first.start() + try: + assert first_zipping.wait(10) + second.start() + assert second_attempted.wait(10) + assert not second_compiled.wait(0.3), "Another export changed active inputs" + finally: + release_zip.set() + for worker in (first, second): + if worker.pid is not None: + worker.join(10) + if worker.is_alive(): + worker.terminate() + worker.join(5) + assert first.exitcode == second.exitcode == 0 + assert (tmp_path / "first/frontend.txt").read_text() == first_url + assert (tmp_path / "second/frontend.txt").read_text() == second_url + + +@pytest.mark.skipif(os.name == "nt", reason="Requires POSIX symlinks") +def test_lock_symlink_cannot_bypass_serialization(cached_build, tmp_path): + """An unsafe lock path stops the build instead of proceeding without exclusion.""" + web, _, process = cached_build + external = tmp_path / "external-lock" + external.write_text("keep") + previous_mode = external.stat().st_mode + (web / ".reflex-build.lock").symlink_to(external) + with pytest.raises(OSError): + build.build() + assert process.call_count == 0 + assert external.read_text() == "keep" + assert external.stat().st_mode == previous_mode + + +@pytest.mark.skipif(os.name == "nt", reason="Requires flock") +def test_lock_failure_does_not_run_an_unlocked_build(cached_build, mocker): + """Lock acquisition failure must propagate before touching another build's output.""" + _, _, process = cached_build + mocker.patch("fcntl.flock", side_effect=OSError("lock unavailable")) + with pytest.raises(OSError, match="lock unavailable"): + build.build() + assert process.call_count == 0 + + +@pytest.mark.skipif(os.name == "nt", reason="Requires raw fork") +def test_forked_child_can_leave_inherited_lock_context(tmp_path): + """A child leaving its parent's context must not operate on closed descriptors.""" + pid = None + child_status = 0 + try: + with build_cache.frontend_build_lock(tmp_path): + pid = os.fork() + except OSError: + if pid != 0: + raise + child_status = 1 + finally: + if pid == 0: + os._exit(child_status) + assert pid is not None + _, status = os.waitpid(pid, 0) + assert os.waitstatus_to_exitcode(status) == 0 diff --git a/tests/units/utils/test_export.py b/tests/units/utils/test_export.py index 9776eb0c043..7eb8105d7ed 100644 --- a/tests/units/utils/test_export.py +++ b/tests/units/utils/test_export.py @@ -16,6 +16,9 @@ def patched_export(mocker: MockerFixture) -> dict: Dict of patched mocks keyed by short name. """ return { + "frontend_build_lock": mocker.patch( + "reflex.utils.build_cache.frontend_build_lock" + ), "get_compiled_app": mocker.patch( "reflex.utils.export.prerequisites.get_compiled_app" ), @@ -27,8 +30,8 @@ def patched_export(mocker: MockerFixture) -> dict: "reflex.utils.export.exec.output_system_info" ), "env_mode_set": mocker.patch( - "reflex.utils.export.environment.REFLEX_ENV_MODE.set" - ), + "reflex.utils.export.environment.REFLEX_ENV_MODE" + ).set, "get_config": mocker.patch( "reflex.utils.export.get_config", return_value=mocker.Mock() ), @@ -126,3 +129,70 @@ def test_export_no_zip_emits_only_compile_and_build_durations(patched_export): assert isinstance(kwargs["setup_duration"], float) assert isinstance(kwargs["build_duration"], float) assert kwargs["zip_duration"] is None + + +@pytest.mark.parametrize( + ("frontend", "zipping", "phases"), + [ + (True, True, ["get_compiled_app", "setup_frontend", "build", "zip_app"]), + (True, False, ["get_compiled_app", "setup_frontend", "build"]), + ], +) +def test_export_holds_frontend_lock_through_packaging( + patched_export, frontend: bool, zipping: bool, phases: list[str] +): + """Generated inputs and both archives belong to one locked export.""" + lock = patched_export["frontend_build_lock"] + context = lock.return_value + + def require_lock(*args, **kwargs): + """Require each export phase to run before the lock is released.""" + context.__enter__.assert_called_once() + context.__exit__.assert_not_called() + + for phase in phases: + patched_export[phase].side_effect = require_lock + patched_export["env_mode_set"].side_effect = require_lock + config = patched_export["get_config"].return_value + config._set_persistent.side_effect = require_lock + + export.export( + frontend=frontend, + zipping=zipping, + api_url="https://api.example.com", + deploy_url="https://app.example.com", + ) + + lock.assert_called_once_with(export.prerequisites.get_web_dir()) + context.__exit__.assert_called_once_with(None, None, None) + assert config._set_persistent.call_count == 2 + + +def test_backend_only_export_does_not_lock_frontend(patched_export): + """A backend-only export does not create or lock a frontend workspace.""" + export.export(frontend=False) + + patched_export["frontend_build_lock"].assert_not_called() + + +@pytest.mark.parametrize( + "failing_target", ["get_compiled_app", "setup_frontend", "build", "zip_app"] +) +def test_export_releases_frontend_lock_before_failure_telemetry( + patched_export, failing_target: str +): + """A failed export releases shared files before reporting its failure.""" + context = patched_export["frontend_build_lock"].return_value + error = RuntimeError("export failed") + patched_export[failing_target].side_effect = error + + def require_release(*args, **kwargs): + """Telemetry runs outside the frontend lock even on failure.""" + context.__enter__.assert_called_once() + context.__exit__.assert_called_once() + + patched_export["send"].side_effect = require_release + with pytest.raises(RuntimeError, match="export failed"): + export.export() + + assert context.__exit__.call_args.args[:2] == (RuntimeError, error) diff --git a/tests/units/utils/test_frontend_skeleton.py b/tests/units/utils/test_frontend_skeleton.py new file mode 100644 index 00000000000..05974f13509 --- /dev/null +++ b/tests/units/utils/test_frontend_skeleton.py @@ -0,0 +1,72 @@ +"""Tests for frontend dependency manifest synchronization.""" + +import json + +import pytest + +from reflex.utils import frontend_skeleton, js_runtimes + + +@pytest.fixture +def package_files(tmp_path, monkeypatch): + """Create persisted and rendered package manifests with isolated paths. + + Returns: + The frontend directory and persisted package manifest path. + """ + web = tmp_path / ".web" + root = tmp_path / "reflex.lock" + web.mkdir() + root.mkdir() + monkeypatch.setattr(frontend_skeleton, "get_web_dir", lambda: web) + monkeypatch.setattr(js_runtimes, "get_web_dir", lambda: web) + monkeypatch.setattr( + frontend_skeleton, "get_root_lockfile_path", lambda name: root / name + ) + manifest = root / "package.json" + manifest.write_text( + json.dumps({"dependencies": {"react": "19.2.8"}, "custom": True}) + ) + (web / "package.json").write_text(frontend_skeleton._compile_package_json()) + return web, manifest + + +@pytest.mark.parametrize("sorted_keys", [False, True]) +def test_package_formatting_preserves_install_cache(package_files, sorted_keys): + """Package manager whitespace/key formatting must not invalidate installed dependencies.""" + web, _ = package_files + package = web / "package.json" + formatted = ( + json.dumps(json.loads(package.read_text()), indent=2, sort_keys=sorted_keys) + + "\n" + ) + package.write_text(formatted) + marker = web / "reflex.install_frontend_packages.cached" + marker.write_bytes(b"unchanged install cache") + before = package.stat().st_mtime_ns + + assert frontend_skeleton.sync_root_package_json_to_web() is False + js_runtimes._sync_root_lockfiles_for_frontend_install() + + assert package.read_text() == formatted + assert package.stat().st_mtime_ns == before + assert marker.read_bytes() == b"unchanged install cache" + + +@pytest.mark.parametrize("value", [False, 1, "true", [True]]) +def test_package_value_changes_invalidate(package_files, value): + """Manifest changes remain significant, including JSON boolean versus number types.""" + web, manifest = package_files + data = json.loads(manifest.read_text()) + data["custom"] = value + manifest.write_text(json.dumps(data)) + assert frontend_skeleton.sync_root_package_json_to_web() is True + assert type(json.loads((web / "package.json").read_text())["custom"]) is type(value) + + +def test_invalid_rendered_package_is_repaired(package_files): + """Malformed generated package JSON must not be treated as a formatting-only change.""" + web, _ = package_files + (web / "package.json").write_text("{bad json") + assert frontend_skeleton.sync_root_package_json_to_web() is True + assert json.loads((web / "package.json").read_text())["custom"] is True