feat(package): ship the web UI in the wheel and serve it from the API

pip install docsgpt now brings the web UI with it: `docsgpt api` serves the
API and the UI on one port.

- scripts/build_frontend.sh builds the frontend into docsgpt/static
  (gitignored) the way the frontend image does: .env.development as the
  production baseline, and index.html loading /config.js ahead of the
  bundle. hatch admits the directory into the wheel and the sdist through
  `artifacts`; the package workflows run the script before `uv build` and
  fail if the wheel lacks the UI. The backend image keeps ignoring it.
- docsgpt/ui.py serves the build in front of Flask: files as they are,
  hashed assets immutable, Flask's own path prefixes (taken from its URL map,
  so new blueprints need no registration) passed through, every other GET
  rendered as index.html for the client-side router. /config.js is generated
  per request with VITE_API_HOST and VITE_BASE_URL set to the page's origin,
  VITE_* environment variables winning. SERVE_UI=false leaves the API alone.
- docsgpt api configures gunicorn in code (gunicorn.app.base.Application)
  instead of rewriting sys.argv, so the SIGUSR2 re-exec that gunicorn uses
  for zero-downtime upgrades runs the docsgpt console script again and
  works; verified with a live handover.
- Docs: the pip page says the UI is included, that DOCSGPT_HOME and
  DOCSGPT_ENV_FILE are process environment variables rather than .env
  entries, and the settings page describes SERVE_UI.
This commit is contained in:
Alex committed 2026-09-09 11:09:09 +01:00
1 parent 0c348917b1
commit 43f493b926
15 files changed
+419 -39

No files matched your search

+2
View File
@@ -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/
+31
View File
@@ -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:
+26
View File
@@ -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: |
+2
View File
@@ -181,6 +181,8 @@ frontend/*.sw?
docsgpt/vectors/
application/vectors/
# The web UI build the package ships (scripts/build_frontend.sh)
docsgpt/static/
**/inputs
+4 -1
View File
@@ -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
@@ -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.
+4 -4
View File
@@ -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).
<Callout type="info">
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.
</Callout>
## 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
```
+9 -1
View File
@@ -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(
+44 -19
View File
@@ -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
+2
View File
@@ -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)
+108
View File
@@ -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)
+7 -3
View File
@@ -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",
+33
View File
@@ -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|<head>|<head><script src="/config.js"></script>|' 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))"
+22 -11
View File
@@ -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)
+123
View File
@@ -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('<html><head><script src="/config.js"></script></head><body>ui</body></html>')
(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