diff --git a/.dockerignore b/.dockerignore index 0521228c..fd316071 100644 --- a/.dockerignore +++ b/.dockerignore @@ -23,3 +23,5 @@ docsgpt/*.pkl docsgpt/.env docsgpt/.env.* docsgpt/Dockerfile +# The backend image serves no UI (the frontend image does); a local UI build stays out. +docsgpt/static/ diff --git a/.github/workflows/package-build.yml b/.github/workflows/package-build.yml index f6cd3296..0be880db 100644 --- a/.github/workflows/package-build.yml +++ b/.github/workflows/package-build.yml @@ -12,6 +12,8 @@ on: - 'docsgpt/**' - 'README.md' - 'LICENSE' + - 'frontend/**' + - 'scripts/build_frontend.sh' - '.github/workflows/package-build.yml' push: branches: [main] @@ -21,6 +23,8 @@ on: - 'docsgpt/**' - 'README.md' - 'LICENSE' + - 'frontend/**' + - 'scripts/build_frontend.sh' - '.github/workflows/package-build.yml' workflow_dispatch: @@ -37,6 +41,17 @@ jobs: - uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6.8.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Build the web UI into the package + env: + CI: "true" + run: bash scripts/build_frontend.sh + - name: Build the sdist and wheel run: uv build @@ -73,6 +88,21 @@ jobs: print(f"wheel ok: {len(names)} files") PY + - name: The wheel carries the web UI and no generated runtime config + run: | + python3 - <<'PY' + import glob + import zipfile + + wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]) + names = set(wheel.namelist()) + assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel" + assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/" + assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime" + assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js" + print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files") + PY + - name: Install the wheel into a clean environment and run the command run: | uv venv "$RUNNER_TEMP/docsgpt-venv" @@ -81,6 +111,7 @@ jobs: "$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" --help "$RUNNER_TEMP/docsgpt-venv/bin/docsgpt" api --help "$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core import paths; print('home:', paths.home_dir())" + "$RUNNER_TEMP/docsgpt-venv/bin/python" -c "from docsgpt.core.paths import package_dir; assert (package_dir() / 'static' / 'index.html').is_file(); print('ui installed')" - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: diff --git a/.github/workflows/pypi-publish.yml b/.github/workflows/pypi-publish.yml index 12adcf9b..9037f758 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -59,9 +59,35 @@ jobs: echo "::error::unknown target '$TARGET': expected pypi or testpypi" exit 1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + # A publishing workflow must not restore a cache another job could have poisoned. + package-manager-cache: false + + - name: Build the web UI into the package + env: + CI: "true" + run: bash scripts/build_frontend.sh + - name: Build the sdist and wheel run: uv build + - name: The wheel carries the web UI and no generated runtime config + run: | + python3 - <<'PY' + import glob + import zipfile + + wheel = zipfile.ZipFile(glob.glob("dist/*.whl")[0]) + names = set(wheel.namelist()) + assert "docsgpt/static/index.html" in names, "the UI build is missing from the wheel" + assert any(n.startswith("docsgpt/static/assets/") for n in names), "no bundle under docsgpt/static/assets/" + assert "docsgpt/static/config.js" not in names, "config.js is generated by the API at runtime" + assert b'src="/config.js"' in wheel.read("docsgpt/static/index.html"), "index.html must load /config.js" + print("ui ok:", sum(n.startswith("docsgpt/static/") for n in names), "files") + PY + - name: The archives carry the released version if: env.RELEASE_VERSION != '' run: | diff --git a/.gitignore b/.gitignore index 8693a33e..f9e72c46 100644 --- a/.gitignore +++ b/.gitignore @@ -181,6 +181,8 @@ frontend/*.sw? docsgpt/vectors/ application/vectors/ +# The web UI build the package ships (scripts/build_frontend.sh) +docsgpt/static/ **/inputs diff --git a/AGENTS.md b/AGENTS.md index d096cf22..b8aeb247 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,7 +43,10 @@ The backend is also an installable package (`pyproject.toml`, hatchling). `docsgpt prefetch-models`, `docsgpt verify-offline`. Runtime data (`.env`, `inputs/`, `indexes/`) lives in the checkout by default; `DOCSGPT_HOME` moves that data home, and `DOCSGPT_ENV_FILE` selects only the `.env` file (see -`docsgpt/core/paths.py`). +`docsgpt/core/paths.py`). `bash scripts/build_frontend.sh` builds the web UI +into `docsgpt/static` (gitignored); the API serves it when present +(`docsgpt/ui.py`, switch `SERVE_UI`), and the package workflows run the script +before `uv build` so the wheel ships it. Dependencies are declared in `pyproject.toml` and locked in `uv.lock`; the `docsgpt/requirements*.txt` files are exported from the lock. To add or diff --git a/docs/content/Deploying/DocsGPT-Settings.mdx b/docs/content/Deploying/DocsGPT-Settings.mdx index 6ac584a7..61d30483 100644 --- a/docs/content/Deploying/DocsGPT-Settings.mdx +++ b/docs/content/Deploying/DocsGPT-Settings.mdx @@ -69,6 +69,8 @@ Here are some of the most fundamental settings you'll likely want to configure: - **`STT_PROVIDER`**: Selects the speech-to-text provider used for microphone transcription in chat and for audio file ingestion through the parser pipeline. +- **`SERVE_UI`**: Whether the API process serves the web UI shipped in the Python package (`pip install docsgpt`). Defaults to `true`; set `false` when a separate frontend, such as the Docker frontend image, serves the UI. Has no effect in the backend Docker image, which carries no UI build. + ## Configuration Examples Let's look at some concrete examples of how to configure these settings in your `.env` file. diff --git a/docs/content/Deploying/Pip-Install.mdx b/docs/content/Deploying/Pip-Install.mdx index 8da950d3..dbfa604b 100644 --- a/docs/content/Deploying/Pip-Install.mdx +++ b/docs/content/Deploying/Pip-Install.mdx @@ -7,10 +7,10 @@ import { Callout } from 'nextra/components' # Install with pip -The DocsGPT backend is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying). +DocsGPT is on PyPI as [`docsgpt`](https://pypi.org/project/docsgpt/): the API server, the web UI, the Celery worker and the maintenance scripts in one package, behind a single `docsgpt` command. Use it when you want DocsGPT inside your own Python environment or process manager rather than the [Docker images](/Deploying/Docker-Deploying). - The package is the backend only. The web UI ships in the Docker images and in the repository under `frontend/`; serving it from the package is planned. + `docsgpt api` serves the web UI on the same port as the API. Set `SERVE_UI=false` to run the API alone, for example behind the frontend Docker image or a UI you host yourself. ## Requirements @@ -49,7 +49,7 @@ pipx runpip docsgpt install --force-reinstall --no-deps --index-url https://down ## Configure -DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. +DocsGPT keeps its runtime files in a **data home**: the `.env` file it reads settings from, uploaded files under `inputs/` and vector indexes under `indexes/`. The data home is the directory you run the commands from, or the directory `DOCSGPT_HOME` points to. `DOCSGPT_ENV_FILE` points at a `.env` kept somewhere else. Both variables must be set in the process environment, not in `.env`: they decide where `.env` is read from. Create a `.env` in the data home. The minimum for a hosted LLM: @@ -72,7 +72,7 @@ Redis defaults to `localhost:6379` (`CELERY_BROKER_URL`, `CELERY_RESULT_BACKEND` ```bash docsgpt migrate # create the database if it is missing and apply the migrations -docsgpt api # the API on http://127.0.0.1:7091 +docsgpt api # the API and the web UI on http://127.0.0.1:7091 docsgpt worker # in a second terminal: the Celery worker, with the scheduler ``` diff --git a/docsgpt/asgi.py b/docsgpt/asgi.py index dd60d4fe..e694fefc 100644 --- a/docsgpt/asgi.py +++ b/docsgpt/asgi.py @@ -12,11 +12,19 @@ from docsgpt.api.async_sse import async_sse_routes from docsgpt.app import app as flask_app from docsgpt.core.settings import settings from docsgpt.mcp_server import mcp +from docsgpt.ui import StaticUI _WSGI_THREADPOOL = int(settings.WSGI_THREADPOOL_WORKERS) mcp_app = mcp.http_app(path="/") +# The web UI, when the package ships one (docsgpt/static) and SERVE_UI is on: +# files are served directly, Flask's own path prefixes pass through, and any +# other GET renders index.html for the client-side router. +_backend = StaticUI.wrap( + WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL), flask_app.url_map, enabled=settings.SERVE_UI +) + asgi_app = Starlette( routes=[ Mount("/mcp", app=mcp_app), @@ -26,7 +34,7 @@ asgi_app = Starlette( # Starlette matches routes top-to-bottom, so these must precede the # Mount("/") that hands everything else to Flask. *async_sse_routes, - Mount("/", app=WSGIMiddleware(flask_app, workers=_WSGI_THREADPOOL)), + Mount("/", app=_backend), ], middleware=[ Middleware( diff --git a/docsgpt/cli.py b/docsgpt/cli.py index 04fe1960..e9d4d670 100644 --- a/docsgpt/cli.py +++ b/docsgpt/cli.py @@ -25,6 +25,49 @@ def _announce_home() -> None: print(f"docsgpt: data home {home_dir()} (env file {env_file()})", file=sys.stderr) +def _gunicorn_options(host: str, port: int, workers: int) -> dict: + """The image's gunicorn flags (docsgpt/Dockerfile CMD), as settings.""" + options = { + "bind": f"{host}:{port}", + "workers": workers, + "worker_class": "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker", + "timeout": 180, + "graceful_timeout": 120, + "keepalive": 5, + "max_requests": 5000, + "max_requests_jitter": 500, + } + if os.path.isdir("/dev/shm"): + options["worker_tmp_dir"] = "/dev/shm" + return options + + +def _gunicorn_application(options: dict): + """A gunicorn application configured in code rather than from sys.argv. + + gunicorn records sys.argv at start and re-executes it on SIGUSR2 (the + zero-downtime upgrade), so sys.argv has to stay the ``docsgpt api ...`` + invocation: the console script is what a re-exec must run again. + """ + from gunicorn.app.base import Application + + class DocsGPTApplication(Application): + def init(self, parser, opts, args): + return None + + def load_config(self): + self.load_config_from_module_name_or_filename("python:docsgpt.gunicorn_conf") + for key, value in options.items(): + self.cfg.set(key, value) + + def load(self): + from docsgpt.asgi import asgi_app + + return asgi_app + + return DocsGPTApplication() + + def _api(args: argparse.Namespace) -> int: """Serve the ASGI app: gunicorn with the bounded-drain uvicorn worker, or uvicorn when reloading.""" _announce_home() @@ -34,25 +77,7 @@ def _api(args: argparse.Namespace) -> int: uvicorn.run("docsgpt.asgi:asgi_app", host=args.host, port=args.port, reload=args.reload) return 0 - from gunicorn.app.wsgiapp import run as gunicorn_run - - argv = [ - "gunicorn", - "-w", str(args.workers), - "-k", "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker", - "--bind", f"{args.host}:{args.port}", - "--timeout", "180", - "--graceful-timeout", "120", - "--keep-alive", "5", - "--max-requests", "5000", - "--max-requests-jitter", "500", - "--config", "python:docsgpt.gunicorn_conf", - ] - if os.path.isdir("/dev/shm"): - argv += ["--worker-tmp-dir", "/dev/shm"] - argv.append("docsgpt.asgi:asgi_app") - sys.argv = argv - gunicorn_run() + _gunicorn_application(_gunicorn_options(args.host, args.port, args.workers)).run() return 0 diff --git a/docsgpt/core/settings.py b/docsgpt/core/settings.py index f926485b..14c834b2 100644 --- a/docsgpt/core/settings.py +++ b/docsgpt/core/settings.py @@ -107,6 +107,8 @@ class Settings(BaseSettings): "request_limit": 500, } UPLOAD_FOLDER: str = "inputs" + # Serve the web UI shipped in the package (docsgpt/static) from the API process. + SERVE_UI: bool = True # Request cap is applied by Flask before multipart parsing; the per-file cap also while copying. UPLOAD_MAX_REQUEST_BYTES: int = Field(default=256 * 1024 * 1024, gt=0) UPLOAD_MAX_FILE_BYTES: int = Field(default=100 * 1024 * 1024, gt=0) diff --git a/docsgpt/ui.py b/docsgpt/ui.py new file mode 100644 index 00000000..04a951e4 --- /dev/null +++ b/docsgpt/ui.py @@ -0,0 +1,108 @@ +"""Serve the built web UI from the API process. + +The wheel ships the frontend build under ``docsgpt/static`` (produced by +``scripts/build_frontend.sh``). When that directory holds an ``index.html`` +and ``SERVE_UI`` is on, the ASGI shell puts :class:`StaticUI` in front of +Flask: files are served as they are, paths that belong to the backend pass +through, and every other GET renders ``index.html`` for the client-side +router. ``/config.js`` is generated per request so the UI talks to the origin +it was loaded from, the same mechanism the nginx image uses. +""" + +from __future__ import annotations + +import json +import logging +import os +from collections.abc import Iterable, Mapping +from pathlib import Path +from typing import Optional + +from starlette.responses import FileResponse, Response +from starlette.types import ASGIApp, Receive, Scope, Send + +from docsgpt.core.paths import package_dir + +logger = logging.getLogger(__name__) + +# Runtime values the page reads before the bundle loads. These default to the +# origin the page came from; a VITE_* variable in the process environment +# wins, so a deployment can still point the UI somewhere else. +_ORIGIN_KEYS = ("VITE_API_HOST", "VITE_BASE_URL") + +# Types Python's mimetypes table may not know. +_MEDIA_TYPES = {".woff2": "font/woff2", ".woff": "font/woff", ".webmanifest": "application/manifest+json"} + +_IMMUTABLE = "public, max-age=31536000, immutable" +_NO_CACHE = "no-cache" + + +def config_js(environ: Optional[Mapping[str, str]] = None) -> str: + """The ``/config.js`` body: ``window.__DOCSGPT_ENV__`` from the VITE_* environment.""" + env = os.environ if environ is None else environ + values = {key: value for key, value in env.items() if key.startswith("VITE_") and value} + entries = [f"{json.dumps(key)}:window.location.origin" for key in _ORIGIN_KEYS if key not in values] + entries += [f"{json.dumps(key)}:{json.dumps(value)}" for key, value in sorted(values.items())] + return "window.__DOCSGPT_ENV__={" + ",".join(entries) + "};\n" + + +class StaticUI: + """ASGI app: the built UI in front of the backend.""" + + def __init__(self, static_dir: Path, backend: ASGIApp, backend_prefixes: Iterable[str]) -> None: + self.static_dir = Path(static_dir).resolve() + self.index = self.static_dir / "index.html" + self.backend = backend + self.backend_prefixes = frozenset(backend_prefixes) + + @classmethod + def wrap(cls, backend: ASGIApp, url_map, static_dir: Optional[Path] = None, enabled: bool = True) -> ASGIApp: + """``backend`` alone when there is no UI to serve (or it is switched off), else the UI in front of it. + + The backend's path prefixes come from Flask's URL map, so a new + blueprint never needs registering here: any first path segment Flask + routes stays Flask's, everything else is the UI's. + """ + static_dir = Path(static_dir) if static_dir is not None else package_dir() / "static" + if not enabled or not (static_dir / "index.html").is_file(): + return backend + prefixes = {rule.rule.split("/")[1] for rule in url_map.iter_rules() if rule.rule != "/"} + logger.info("Serving the web UI from %s", static_dir) + return cls(static_dir, backend, prefixes) + + def _file(self, path: str) -> Optional[Path]: + """The file under the static directory for ``path``, or None (never a path outside it).""" + relative = path.lstrip("/") + if not relative: + return None + try: + resolved = (self.static_dir / relative).resolve() + except OSError: + return None + if self.static_dir not in resolved.parents: + return None + return resolved if resolved.is_file() else None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope["method"] not in ("GET", "HEAD"): + await self.backend(scope, receive, send) + return + path = scope["path"] + root = scope.get("root_path", "") + if root and path.startswith(root): + path = path[len(root):] or "/" + if path == "/config.js": + response = Response(config_js(), media_type="application/javascript", headers={"Cache-Control": "no-store"}) + await response(scope, receive, send) + return + first = path.split("/")[1] if len(path) > 1 else "" + if first in self.backend_prefixes: + await self.backend(scope, receive, send) + return + file = self._file(path) + if file is None: + file, cache = self.index, _NO_CACHE + else: + cache = _IMMUTABLE if first == "assets" else _NO_CACHE + response = FileResponse(file, media_type=_MEDIA_TYPES.get(file.suffix), headers={"Cache-Control": cache}) + await response(scope, receive, send) diff --git a/pyproject.toml b/pyproject.toml index 99fcf8a0..e1788ccc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -170,11 +170,14 @@ dev = [ path = "docsgpt/version.py" # The wheel is the docsgpt package with the data it reads at runtime: prompts, -# model catalogs, the seed config, alembic.ini and the migrations. Build inputs -# (Dockerfile, exported requirements), the sample index and local runtime data -# stay out. The one-release `application` import alias is checkout-only. +# model catalogs, the seed config, alembic.ini and the migrations, plus the web +# UI build under docsgpt/static (gitignored; scripts/build_frontend.sh makes +# it, and `artifacts` admits it). Build inputs (Dockerfile, exported +# requirements), the sample index and local runtime data stay out. The +# one-release `application` import alias is checkout-only. [tool.hatch.build.targets.wheel] packages = ["docsgpt"] +artifacts = ["docsgpt/static/**"] exclude = [ "docsgpt/Dockerfile", "docsgpt/requirements*.txt", @@ -187,6 +190,7 @@ exclude = [ [tool.hatch.build.targets.sdist] include = ["/docsgpt"] +artifacts = ["docsgpt/static/**"] exclude = [ "docsgpt/Dockerfile", "docsgpt/requirements*.txt", diff --git a/scripts/build_frontend.sh b/scripts/build_frontend.sh new file mode 100755 index 00000000..294d2b11 --- /dev/null +++ b/scripts/build_frontend.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Build the web UI into docsgpt/static so the Python package ships it. +# +# The API serves that directory when it exists (docsgpt/ui.py). The package +# workflows run this before `uv build`; run it locally to test the served UI +# or to build a wheel with the UI in it. +set -euo pipefail + +cd "$(dirname "$0")/../frontend" + +if [ ! -d node_modules ] || [ "${CI:-}" = "true" ]; then + npm ci --include=dev +fi + +if [ -f .env.local ]; then + echo "note: frontend/.env.local exists; its VITE_* values are baked into this build" >&2 +fi + +# As in frontend/Dockerfile: the committed .env.development is the baseline +# of the production build. The API rewrites VITE_API_HOST and VITE_BASE_URL +# at runtime through /config.js, so those baked values never reach a browser. +cp .env.development .env.production.local +trap 'rm -f .env.production.local' EXIT +npm run build + +# Load the runtime config ahead of the bundle, as the nginx image does. +sed -i.bak 's|||' dist/index.html +rm -f dist/index.html.bak +grep -q 'src="/config.js"' dist/index.html + +rm -rf ../docsgpt/static +cp -R dist ../docsgpt/static +echo "built docsgpt/static ($(du -sh ../docsgpt/static | cut -f1))" diff --git a/tests/test_cli.py b/tests/test_cli.py index af60c7df..c8f934ce 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -35,21 +35,32 @@ class TestTopLevel: class TestApi: - def test_gunicorn_argv(self, monkeypatch, capsys): - run = MagicMock() - monkeypatch.setitem(sys.modules, "gunicorn.app.wsgiapp", types.SimpleNamespace(run=run)) + def test_gunicorn_runs_with_the_image_settings_and_leaves_argv_alone(self, monkeypatch, capsys): + application = MagicMock() + factory = MagicMock(return_value=application) + monkeypatch.setattr(cli, "_gunicorn_application", factory) monkeypatch.setattr(sys, "platform", "linux") - monkeypatch.setattr(sys, "argv", ["docsgpt"]) + monkeypatch.setattr(sys, "argv", ["/venv/bin/docsgpt", "api", "--port", "8000"]) assert cli.main(["api", "--port", "8000", "--workers", "2"]) == 0 - run.assert_called_once_with() - argv = sys.argv - assert argv[0] == "gunicorn" and argv[-1] == "docsgpt.asgi:asgi_app" - assert argv[argv.index("--bind") + 1] == "127.0.0.1:8000" - assert argv[argv.index("-w") + 1] == "2" - assert argv[argv.index("-k") + 1] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" - assert argv[argv.index("--config") + 1] == "python:docsgpt.gunicorn_conf" + application.run.assert_called_once_with() + options = factory.call_args.args[0] + assert options["bind"] == "127.0.0.1:8000" + assert options["workers"] == 2 + assert options["worker_class"] == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" + assert options["max_requests"] == 5000 + # gunicorn re-executes sys.argv on SIGUSR2; it must still be the docsgpt invocation. + assert sys.argv == ["/venv/bin/docsgpt", "api", "--port", "8000"] assert "data home" in capsys.readouterr().err + def test_the_gunicorn_application_carries_the_settings_and_the_log_config(self): + app = cli._gunicorn_application(cli._gunicorn_options("127.0.0.1", 8001, 3)) + assert app.cfg.bind == ["127.0.0.1:8001"] + assert app.cfg.workers == 3 + assert app.cfg.worker_class_str == "docsgpt.gunicorn_worker.BoundedDrainUvicornWorker" + assert app.cfg.keepalive == 5 + assert app.cfg.graceful_timeout == 120 + assert "ncsa_access" in app.cfg.logconfig_dict["formatters"], "docsgpt.gunicorn_conf was loaded" + def test_reload_uses_uvicorn(self, monkeypatch): uvicorn = types.SimpleNamespace(run=MagicMock()) monkeypatch.setitem(sys.modules, "uvicorn", uvicorn) diff --git a/tests/test_ui.py b/tests/test_ui.py new file mode 100644 index 00000000..4a1045dd --- /dev/null +++ b/tests/test_ui.py @@ -0,0 +1,123 @@ +"""The web UI shipped in the package is served in front of the API (docsgpt.ui).""" + +import pytest +from a2wsgi import WSGIMiddleware +from flask import Flask +from starlette.applications import Starlette +from starlette.routing import Mount +from starlette.testclient import TestClient + +from docsgpt.ui import StaticUI, config_js + + +@pytest.fixture +def static_dir(tmp_path): + (tmp_path / "index.html").write_text('ui') + (tmp_path / "assets").mkdir() + (tmp_path / "assets" / "index-abc123.js").write_text("console.log('bundle')") + (tmp_path / "fonts").mkdir() + (tmp_path / "fonts" / "inter.woff2").write_bytes(b"\x00\x01") + (tmp_path / "favicon.ico").write_bytes(b"\x00") + return tmp_path + + +def _backend() -> Flask: + app = Flask("backend") + + @app.route("/") + def home(): + return "backend home" + + @app.route("/api/health") + def health(): + return {"status": "ok"} + + @app.route("/api/echo", methods=["POST"]) + def echo(): + return "posted" + + return app + + +@pytest.fixture +def client(static_dir): + flask_app = _backend() + ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir) + assert isinstance(ui, StaticUI) + return TestClient(Starlette(routes=[Mount("/", app=ui)])) + + +class TestRouting: + def test_root_is_the_ui_not_the_backend_home(self, client): + response = client.get("/") + assert response.status_code == 200 + assert "ui" in response.text and "backend home" not in response.text + assert response.headers["cache-control"] == "no-cache" + + def test_client_side_routes_render_index(self, client): + assert client.get("/agents/123/edit").text == client.get("/").text + + def test_hashed_assets_are_immutable(self, client): + response = client.get("/assets/index-abc123.js") + assert response.status_code == 200 + assert "bundle" in response.text + assert response.headers["cache-control"] == "public, max-age=31536000, immutable" + assert "javascript" in response.headers["content-type"] + + def test_font_has_a_media_type(self, client): + assert client.get("/fonts/inter.woff2").headers["content-type"] == "font/woff2" + + def test_backend_prefixes_pass_through(self, client): + assert client.get("/api/health").json() == {"status": "ok"} + missing = client.get("/api/does-not-exist") + assert missing.status_code == 404 + assert "ui" not in missing.text, "an unknown API path is the backend's 404, not the UI" + + def test_writes_never_hit_the_ui(self, client): + assert client.post("/api/echo").text == "posted" + assert client.post("/settings").status_code in (404, 405) + + def test_head_works(self, client): + response = client.head("/") + assert response.status_code == 200 + assert response.content == b"" + + +class TestConfigJs: + def test_defaults_to_the_page_origin(self, client): + response = client.get("/config.js") + assert response.status_code == 200 + assert response.headers["cache-control"] == "no-store" + assert '"VITE_API_HOST":window.location.origin' in response.text + assert '"VITE_BASE_URL":window.location.origin' in response.text + + def test_environment_values_win_and_are_escaped(self): + body = config_js({"VITE_API_HOST": "https://api.example.com", "VITE_NOTE": 'say "hi"\\', "OTHER": "x", "VITE_EMPTY": ""}) + assert '"VITE_API_HOST":"https://api.example.com"' in body + assert '"VITE_BASE_URL":window.location.origin' in body + assert '"VITE_NOTE":"say \\"hi\\"\\\\"' in body + assert "OTHER" not in body and "VITE_EMPTY" not in body + assert body.startswith("window.__DOCSGPT_ENV__={") and body.endswith("};\n") + + +class TestSafety: + def test_paths_outside_the_static_dir_are_never_served(self, static_dir): + flask_app = _backend() + ui = StaticUI.wrap(WSGIMiddleware(flask_app), flask_app.url_map, static_dir=static_dir) + (static_dir.parent / "secret.txt").write_text("nope") + assert ui._file("/../secret.txt") is None + assert ui._file("/assets/../../secret.txt") is None + assert ui._file("/") is None + assert ui._file("/assets") is None, "directories are not files" + + +class TestWrap: + def test_no_build_means_the_backend_alone(self, tmp_path): + flask_app = _backend() + backend = WSGIMiddleware(flask_app) + assert StaticUI.wrap(backend, flask_app.url_map, static_dir=tmp_path) is backend + + def test_switched_off_means_the_backend_alone(self, static_dir): + flask_app = _backend() + backend = WSGIMiddleware(flask_app) + assert StaticUI.wrap(backend, flask_app.url_map, static_dir=static_dir, enabled=False) is backend