diff --git a/.github/workflows/linux-installer.yml b/.github/workflows/linux-installer.yml index 5ceaee3a..630c8557 100644 --- a/.github/workflows/linux-installer.yml +++ b/.github/workflows/linux-installer.yml @@ -55,6 +55,27 @@ jobs: fi echo "version=${version}" >> "$GITHUB_OUTPUT" + - name: Set up Node.js (web build) + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: web/package-lock.json + + # The web/vis frontends are gitignored build artifacts; without these + # steps PyInstaller froze a binary whose web UI 404'd on "/". The + # bundles are arch-independent, so they are built once on the host — + # the QEMU container build picks them up from the mounted checkout. + # The spec file now also refuses to freeze when the bundles are missing. + - name: Build web UI bundle + env: + PYTHINKER_WEB_STRICT_VERSION: "1" + PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }} + run: python3 scripts/build_web.py + + - name: Build vis UI bundle + run: python3 scripts/build_vis.py + - name: Set up QEMU (aarch64 only) if: matrix.qemu uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # pinned from v4.1.0 diff --git a/.github/workflows/windows-installer.yml b/.github/workflows/windows-installer.yml index f86fd212..cd9cbcd4 100644 --- a/.github/workflows/windows-installer.yml +++ b/.github/workflows/windows-installer.yml @@ -54,6 +54,25 @@ jobs: - name: Sync project dependencies run: uv sync --frozen --no-dev + - name: Set up Node.js (web build) + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # pinned from v6.4.0 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: web/package-lock.json + + # The web/vis frontends are gitignored build artifacts; without these + # steps PyInstaller froze a binary whose web UI 404'd on "/". The spec + # file now also refuses to freeze when the bundles are missing. + - name: Build web UI bundle + env: + PYTHINKER_WEB_STRICT_VERSION: "1" + PYTHINKER_WEB_EXPECT_VERSION: ${{ steps.ver.outputs.version }} + run: python scripts/build_web.py + + - name: Build vis UI bundle + run: python scripts/build_vis.py + - name: Install PyInstaller run: uv pip install pyinstaller diff --git a/CHANGELOG.md b/CHANGELOG.md index 4abc556c..796f5911 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ GitHub Releases page; `0.8.0` is the new starting line. ## Unreleased +- **Windows/Linux native installers: web UI no longer 404s on `/`.** The installer CI froze the app without building the gitignored web/vis frontend bundles, so `pythinker web` opened a browser onto `GET /?token=… → 404 Not Found`. Both installer workflows now build the bundles before PyInstaller (matching the PyPI release flow — pip/wheel installs were never affected), every PyInstaller spec refuses to freeze when the bundles are missing, and a build that still lacks them serves an explanatory page on `/` (with the REST API still reachable under `/api`) instead of a bare 404. +- **Startup banner renders on legacy Windows consoles.** The `pythinker web` / `pythinker vis` PYTHINKER banner raw-printed Unicode block art, which garbled on legacy code pages (e.g. PowerShell with cp1252) and raised `UnicodeEncodeError` when output was redirected. The banner now honors the existing ASCII-glyph detection (`PYTHINKER_ASCII_UI` / `PYTHINKER_TUI_GLYPHS=ascii` opt-ins included) with width-preserving ASCII fallbacks, and degrades per line instead of crashing when a stream rejects Unicode. + ## 0.40.0 (2026-06-10) - **Web: same-origin WebSockets accepted, version banner synced to the backend, and token bootstrap race fixed.** The local-mode web server now auto-populates the allowed-origin list (an empty allowlist rejects every `Origin`-bearing request, which previously broke all WebSocket handshakes with a 403). The UI version banner prefers the version the running backend reports (via the config API) over the stale build-time constant, and a transient version-fetch failure no longer permanently disables the backend banner for the session. The initial auth-token bootstrap race that could fail the first request is resolved. `ESC` now reliably terminates only the background tasks spawned by the interrupted turn, and recall context is re-framed so prior-session snippets can't be misread as new instructions. diff --git a/packages/linux-installer/pythinker.spec b/packages/linux-installer/pythinker.spec index 0a6b208c..507e2328 100644 --- a/packages/linux-installer/pythinker.spec +++ b/packages/linux-installer/pythinker.spec @@ -3,8 +3,30 @@ # / tarball). Mode: --onedir — fpm wraps the directory into the package and # install-native.sh tar-gzips it for the curl-bash flow. +import importlib.util +from pathlib import Path + from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata +# The web/vis frontends are gitignored build artifacts. collect_data_files() +# silently collects nothing when they are missing, which shipped installers +# whose web UI answered "/" with a 404. Fail the freeze loudly instead. +_pkg_spec = importlib.util.find_spec("pythinker_code") +if _pkg_spec is None or _pkg_spec.origin is None: + raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment") +_pkg_root = Path(_pkg_spec.origin).resolve().parent +_missing_ui = [ + rel + for rel in ("web/static/index.html", "vis/static/index.html") + if not (_pkg_root / rel).is_file() +] +if _missing_ui: + raise SystemExit( + f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). " + "Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) " + "before freezing, or the packaged web UI will 404 on '/'." + ) + block_cipher = None hiddenimports = [] diff --git a/packages/windows-installer/pythinker.spec b/packages/windows-installer/pythinker.spec index 51241d21..2aa430f6 100644 --- a/packages/windows-installer/pythinker.spec +++ b/packages/windows-installer/pythinker.spec @@ -2,8 +2,30 @@ # PyInstaller spec for the Pythinker Code Windows native build. # Mode: --onedir (faster startup, fewer AV false-positives than --onefile). +import importlib.util +from pathlib import Path + from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata +# The web/vis frontends are gitignored build artifacts. collect_data_files() +# silently collects nothing when they are missing, which shipped installers +# whose web UI answered "/" with a 404. Fail the freeze loudly instead. +_pkg_spec = importlib.util.find_spec("pythinker_code") +if _pkg_spec is None or _pkg_spec.origin is None: + raise SystemExit("pythinker.spec: pythinker_code is not installed in the build environment") +_pkg_root = Path(_pkg_spec.origin).resolve().parent +_missing_ui = [ + rel + for rel in ("web/static/index.html", "vis/static/index.html") + if not (_pkg_root / rel).is_file() +] +if _missing_ui: + raise SystemExit( + f"pythinker.spec: UI bundles missing from pythinker_code ({', '.join(_missing_ui)}). " + "Run `make build-web build-vis` (or scripts/build_web.py and scripts/build_vis.py) " + "before freezing, or the packaged web UI will 404 on '/'." + ) + block_cipher = None hiddenimports = [] diff --git a/pythinker.spec b/pythinker.spec index adcfeeaa..79ea9732 100644 --- a/pythinker.spec +++ b/pythinker.spec @@ -1,7 +1,11 @@ # -*- mode: python ; coding: utf-8 -*- import os -from pythinker_code.utils.pyinstaller import datas, hiddenimports +from pythinker_code.utils.pyinstaller import datas, hiddenimports, require_ui_assets + +# Fail loud when the gitignored web/vis bundles haven't been built; a freeze +# without them ships a binary whose web UI 404s on "/". +require_ui_assets() # Read codesign identity from environment variable (for macOS signing in CI) codesign_identity = os.environ.get("APPLE_SIGNING_IDENTITY", None) diff --git a/src/pythinker_code/utils/pyinstaller.py b/src/pythinker_code/utils/pyinstaller.py index f767b286..c52a8719 100644 --- a/src/pythinker_code/utils/pyinstaller.py +++ b/src/pythinker_code/utils/pyinstaller.py @@ -1,9 +1,32 @@ from __future__ import annotations +from pathlib import Path + from PyInstaller.utils.hooks import collect_data_files, collect_submodules, copy_metadata from pythinker_code.cli._lazy_group import LazySubcommandGroup +# The web/vis frontends are gitignored build artifacts synced into the package +# by scripts/build_web.py and scripts/build_vis.py. collect_data_files() +# silently collects nothing for missing globs, which froze builds whose web UI +# answered "/" with a 404 (Windows/Linux native installers). +_REQUIRED_UI_ASSETS = ("web/static/index.html", "vis/static/index.html") + + +def require_ui_assets(package_root: Path | None = None) -> None: + """Abort the freeze when the web/vis UI bundles haven't been built.""" + if package_root is None: + package_root = Path(__file__).resolve().parents[1] + missing = [rel for rel in _REQUIRED_UI_ASSETS if not (package_root / rel).is_file()] + if missing: + raise SystemExit( + "PyInstaller build aborted: UI bundles missing from pythinker_code " + f"({', '.join(missing)}). They are gitignored build artifacts; run " + "`make build-web build-vis` before freezing, or the packaged web UI " + "will 404 on '/'." + ) + + lazy_cli_hiddenimports = [ module_name for module_name, _attribute_name, _help_text in (LazySubcommandGroup.lazy_subcommands.values()) diff --git a/src/pythinker_code/utils/server.py b/src/pythinker_code/utils/server.py index 6c1d963f..d61cdd88 100644 --- a/src/pythinker_code/utils/server.py +++ b/src/pythinker_code/utils/server.py @@ -4,8 +4,11 @@ import importlib import socket +import sys import textwrap +from pythinker_code.ui.terminal_capabilities import ascii_glyphs_enabled + # Shared "PYTHINKER" wordmark used by the web and vis startup banners. PYTHINKER_BANNER_ART = [ "
██████╗ ██╗ ██╗████████╗██╗ ██╗██╗███╗ ██╗██╗ ██╗███████╗██████╗ ", @@ -16,6 +19,37 @@ "
╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝", ] +# Single-cell ASCII stand-ins for every non-ASCII character the banners emit. +# All replacements are 1:1 so box alignment is preserved. Used when the +# terminal can't render Unicode (legacy Windows code pages such as cp1252, +# TERM=dumb, or an explicit PYTHINKER_ASCII_UI/PYTHINKER_TUI_GLYPHS opt-in); +# raw print() of the block art would otherwise garble or raise +# UnicodeEncodeError on those streams. +_BANNER_ASCII_FALLBACKS = str.maketrans( + { + "█": "#", + "╔": " ", + "╗": " ", + "╚": " ", + "╝": " ", + "║": " ", + "═": " ", + "➜": ">", + "•": "*", + "⚠": "!", + } +) + + +def _print_banner_line(text: str) -> None: + """Print one banner line, degrading to ASCII if the stream rejects it.""" + try: + print(text) + except UnicodeEncodeError: + encoding = getattr(sys.stdout, "encoding", None) or "ascii" + fallback = text.translate(_BANNER_ASCII_FALLBACKS) + print(fallback.encode(encoding, errors="replace").decode(encoding, errors="replace")) + def get_address_family(host: str) -> socket.AddressFamily: """Return AF_INET6 for IPv6 addresses, AF_INET for IPv4 and hostnames.""" @@ -96,8 +130,39 @@ def get_network_addresses() -> list[str]: return addresses +def missing_ui_page(asset_path: str, build_command: str) -> str: + """HTML served on ``/`` when the bundled frontend assets are absent. + + Packaged builds that skipped the frontend build would otherwise answer + ``/`` with a bare 404, which reads like a routing bug instead of a + packaging one. + """ + return f""" + + + + Pythinker UI unavailable + + +

UI assets are missing

+

This Pythinker build was packaged without its frontend bundle + ({asset_path} was not found).

+

If you installed a packaged build (Windows installer, winget, scoop, + .deb/.rpm), update to the latest release or report this as a packaging + bug. When running from source, build the frontend first:

+
{build_command}
+

The REST API is unaffected and remains available under /api.

+ + +""" + + def print_banner(lines: list[str]) -> None: """Print a boxed banner with tag conventions (
, ,
).""" + if ascii_glyphs_enabled(): + lines = [line.translate(_BANNER_ASCII_FALLBACKS) for line in lines] + processed: list[str] = [] for line in lines: if line == "
": @@ -113,19 +178,21 @@ def strip_tags(s: str) -> str: return s.removeprefix("
").removeprefix("") content_lines = [strip_tags(line) for line in processed if line != "
"] - width = max(60, *(len(line) for line in content_lines)) + # The leading 60 lives inside the list: `max(60, *[])` is a TypeError when + # every line is an
. + width = max([60, *(len(line) for line in content_lines)]) top = "+" + "=" * (width + 2) + "+" - print(top) + _print_banner_line(top) for line in processed: if line == "
": - print("|" + "-" * (width + 2) + "|") + _print_banner_line("|" + "-" * (width + 2) + "|") elif line.startswith("
"): content = line.removeprefix("
") - print(f"| {content.center(width)} |") + _print_banner_line(f"| {content.center(width)} |") elif line.startswith(""): content = line.removeprefix("") - print(f"| {content.ljust(width)} |") + _print_banner_line(f"| {content.ljust(width)} |") else: - print(f"| {line.ljust(width)} |") - print(top) + _print_banner_line(f"| {line.ljust(width)} |") + _print_banner_line(top) diff --git a/src/pythinker_code/vis/app.py b/src/pythinker_code/vis/app.py index 48ac23a8..ad8bf2b9 100644 --- a/src/pythinker_code/vis/app.py +++ b/src/pythinker_code/vis/app.py @@ -10,6 +10,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import HTMLResponse from fastapi.staticfiles import StaticFiles from pythinker_code.utils.server import ( @@ -18,6 +19,7 @@ format_url, get_network_addresses, is_local_host, + missing_ui_page, print_banner, ) from pythinker_code.vis.api import sessions_router, statistics_router, system_router @@ -82,8 +84,18 @@ def create_app() -> FastAPI: async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunction] return {"status": "ok"} - if STATIC_DIR.exists(): + # Gate on index.html, not just the directory: a build that skipped the vis + # bundle would otherwise answer "/" with a bare 404 instead of a hint. + if (STATIC_DIR / "index.html").is_file(): application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + else: + + @application.get("/", include_in_schema=False) + async def missing_vis_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction] + return HTMLResponse( + status_code=503, + content=missing_ui_page("pythinker_code/vis/static/index.html", "make build-vis"), + ) return application diff --git a/src/pythinker_code/web/app.py b/src/pythinker_code/web/app.py index d509a133..ebc12791 100644 --- a/src/pythinker_code/web/app.py +++ b/src/pythinker_code/web/app.py @@ -25,6 +25,7 @@ format_url, get_network_addresses, is_local_host, + missing_ui_page, ) from pythinker_code.web.api import ( config_router, @@ -218,9 +219,24 @@ async def health_probe() -> dict[str, Any]: # pyright: ignore[reportUnusedFunct """Health check endpoint.""" return {"status": "ok"} - # Mount static files as fallback (must be last) - if STATIC_DIR.exists(): + # Mount static files as fallback (must be last). Gate on index.html, not + # just the directory: a build that skipped the web bundle can still ship + # the git-tracked brand/ files, and StaticFiles(html=True) would then 404 + # on "/" with no hint of why. + if (STATIC_DIR / "index.html").is_file(): application.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + else: + logger.warning( + "Web UI assets missing at {static_dir}; serving setup instructions on /", + static_dir=STATIC_DIR, + ) + + @application.get("/", include_in_schema=False) + async def missing_web_ui() -> HTMLResponse: # pyright: ignore[reportUnusedFunction] + return HTMLResponse( + status_code=503, + content=missing_ui_page("pythinker_code/web/static/index.html", "make build-web"), + ) return application diff --git a/tasks/todo.md b/tasks/todo.md index 43879b94..c175bece 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,61 @@ +# Task: Windows web 404 + PowerShell banner rendering (2026-06-10) + +## Diagnosis (verified) + +1. **`GET /?token=…` → 404 on Windows.** `src/pythinker_code/web/static/` and + `vis/static/` are gitignored build artifacts (`.gitignore:59`); only two + `web/static/brand/` files are tracked. `windows-installer.yml` and + `linux-installer.yml` freeze with PyInstaller **without building the web/vis + frontends** (unlike `release-pythinker-cli.yml`, which sets up Node and runs + the builds — the published 0.40.0 PyPI wheel contains all 448 web/static + files, so pip installs are fine). `collect_data_files()` silently collects + only the brand files → frozen app's `STATIC_DIR.exists()` is True but + `index.html` is missing → `StaticFiles(html=True)` 404s on `/`. +2. **Banner garbled in PowerShell.** `print_banner()` (utils/server.py) raw- + prints Unicode block/box art. Verified it cannot encode to cp1252 → + UnicodeEncodeError on redirected Windows stdout, garbling in legacy + consoles. Existing `ascii_glyphs_enabled()` fallback infra is bypassed. + (Alignment itself is correct — all banner lines are exactly equal width; + the "long line" in the report was a paste artifact.) + +## Plan + +- [x] utils/server.py: ASCII fallback translation (1:1 width-preserving) wired + to `ascii_glyphs_enabled()` + crash-proof printing on UnicodeEncodeError. + → verified: 6 new tests in tests/utils/test_server.py + live cp1252 run +- [x] web/app.py + vis/app.py: gate mount on `index.html`, serve explanatory + 503 page instead of bare 404 when assets missing. + → verified: tests/web/test_web_ui_assets.py (web + vis) +- [x] utils/pyinstaller.py helper + root pythinker.spec + both installer specs: + fail the freeze loudly when web/vis bundles are missing. + → verified: 2 tests in tests/utils/test_pyinstaller_utils.py + default-path run +- [x] windows-installer.yml + linux-installer.yml: set up Node and build + web/vis bundles before PyInstaller (mirrors release-pythinker-cli.yml). + → verified: YAML parses; spec syntax parses; spec guard enforces assets +- [x] CHANGELOG.md Unreleased entry. +- [x] Run ruff + pyright + targeted pytest. +- [x] Bonus bug found during run: `print_banner` crashed with TypeError on an + all-`
` banner (`max(60, *[])`); fixed + regression test. + +## Review + +- Verification: `ruff check src tests` clean, `ruff format --check` clean, + `pyright` 0 errors on all touched src files (strict mode), typos clean, + 428 passed / 1 skipped across tests/utils + tests/web + tests/vis. +- Live repro of the user scenario: `PYTHONIOENCODING=cp1252` run now prints a + perfectly aligned ASCII banner instead of raising UnicodeEncodeError. +- clean-code-guard pass: no violations; one documented exception — the + required-UI-assets check is inlined in both installer specs in addition to + the shared `require_ui_assets()` helper, because those specs are + deliberately self-contained (their own comments forbid importing the shared + datas module). +- Out of scope (observed, not changed): `except (ImportError, Exception)` at + utils/server.py get_network_addresses is redundant (Exception covers + ImportError) — pre-existing, harmless; `➜`/`⚠` may render double-width in + some Windows fonts (cosmetic; ASCII opt-ins now cover it). + +--- + # Codex TUI adoption — Phase 1 (foundation) Source of truth: `blackbox/codex-main/codex-rs/tui`. Backlog: user-provided 48-item diff --git a/tests/utils/test_pyinstaller_utils.py b/tests/utils/test_pyinstaller_utils.py index 0dbe0580..27edaa47 100644 --- a/tests/utils/test_pyinstaller_utils.py +++ b/tests/utils/test_pyinstaller_utils.py @@ -5,6 +5,7 @@ from importlib.metadata import version from pathlib import Path +import pytest from inline_snapshot import snapshot @@ -332,3 +333,23 @@ def test_pyinstaller_hiddenimports_include_lazy_cli_subcommands(): } assert expected_hiddenimports <= set(hiddenimports) + + +def test_require_ui_assets_accepts_built_tree(tmp_path: Path) -> None: + from pythinker_code.utils.pyinstaller import require_ui_assets + + for rel in ("web/static/index.html", "vis/static/index.html"): + target = tmp_path / rel + target.parent.mkdir(parents=True) + target.write_text("") + + require_ui_assets(tmp_path) # must not raise + + +def test_require_ui_assets_rejects_unbuilt_tree(tmp_path: Path) -> None: + """A freeze without the gitignored web/vis bundles ships a web UI that + 404s on "/"; the guard must abort the build with an actionable message.""" + from pythinker_code.utils.pyinstaller import require_ui_assets + + with pytest.raises(SystemExit, match="build-web"): + require_ui_assets(tmp_path) diff --git a/tests/utils/test_server.py b/tests/utils/test_server.py index 0278c111..ee7ff0dd 100644 --- a/tests/utils/test_server.py +++ b/tests/utils/test_server.py @@ -5,11 +5,13 @@ import pytest from pythinker_code.utils.server import ( + PYTHINKER_BANNER_ART, find_available_port, format_url, get_address_family, get_network_addresses, is_local_host, + print_banner, ) # --------------------------------------------------------------------------- @@ -119,3 +121,99 @@ def test_returns_list(self) -> None: def test_no_loopback(self) -> None: for addr in get_network_addresses(): assert not addr.startswith("127.") + + +# --------------------------------------------------------------------------- +# print_banner — Unicode wordmark with ASCII fallbacks for legacy terminals +# --------------------------------------------------------------------------- + + +_GLYPH_ENV_VARS = ("PYTHINKER_TUI_GLYPHS", "PYTHINKER_ASCII_UI", "PYTHINKER_SAFE_GLYPHS") + + +def _sample_banner_lines() -> list[str]: + return [ + *PYTHINKER_BANNER_ART, + "", + "
WEB UI (Technical Preview)", + "
", + " ➜ Local http://localhost:5494/?token=abc123", + " • Use -n / --network to share on LAN", + " ⚠ Sensitive APIs are restricted", + ] + + +class TestPrintBanner: + def test_all_lines_share_one_width( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + for var in _GLYPH_ENV_VARS: + monkeypatch.delenv(var, raising=False) + print_banner(_sample_banner_lines()) + widths = {len(line) for line in capsys.readouterr().out.splitlines()} + assert len(widths) == 1 + + def test_ascii_opt_in_strips_unicode_and_keeps_alignment( + self, capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv("PYTHINKER_ASCII_UI", "1") + print_banner(_sample_banner_lines()) + out = capsys.readouterr().out + assert out.isascii() + assert "#" in out # wordmark survives as its block silhouette + assert len({len(line) for line in out.splitlines()}) == 1 + + def test_legacy_windows_codepage_stdout_does_not_crash( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """PowerShell with redirected output encodes stdout as cp1252. + + The block/box wordmark is not representable there; the banner must + degrade to ASCII instead of raising UnicodeEncodeError at startup. + """ + import io + import sys + + for var in _GLYPH_ENV_VARS: + monkeypatch.delenv(var, raising=False) + buffer = io.BytesIO() + stream = io.TextIOWrapper(buffer, encoding="cp1252", newline="") + monkeypatch.setattr(sys, "stdout", stream) + print_banner(_sample_banner_lines()) + stream.flush() + out = buffer.getvalue().decode("cp1252") + assert "#" in out + assert "+" in out # box border intact + + def test_stream_rejecting_unicode_falls_back_per_line( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Even when encoding detection can't see the limitation, a stream + that raises UnicodeEncodeError gets the ASCII rendition, not a crash.""" + import io + import sys + + for var in _GLYPH_ENV_VARS: + monkeypatch.delenv(var, raising=False) + + class _StrictAsciiStdout(io.TextIOBase): + def __init__(self) -> None: + self.chunks: list[str] = [] + + def write(self, s: str) -> int: + s.encode("ascii") + self.chunks.append(s) + return len(s) + + stream = _StrictAsciiStdout() + monkeypatch.setattr(sys, "stdout", stream) + print_banner(_sample_banner_lines()) + out = "".join(stream.chunks) + assert "#" in out + assert "share on LAN" in out + + def test_hr_only_banner_does_not_crash(self, capsys: pytest.CaptureFixture[str]) -> None: + """`max(60, *[])` is a TypeError; the minimum width must apply.""" + print_banner(["
"]) + out = capsys.readouterr().out.splitlines() + assert len({len(line) for line in out}) == 1 diff --git a/tests/web/test_web_ui_assets.py b/tests/web/test_web_ui_assets.py new file mode 100644 index 00000000..b6046d80 --- /dev/null +++ b/tests/web/test_web_ui_assets.py @@ -0,0 +1,39 @@ +"""When the bundled frontend is missing, "/" must explain why, not 404. + +Regression guard for the native installers that froze without building the +gitignored web/vis bundles: the served app answered ``GET /?token=...`` with a +bare 404 (see windows-installer.yml / linux-installer.yml web build steps). +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from starlette.testclient import TestClient + + +def test_web_root_explains_missing_assets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import pythinker_code.web.app as web_app + + monkeypatch.setattr(web_app, "STATIC_DIR", tmp_path) + app = web_app.create_app(session_token="test-token") + client = TestClient(app) + client.cookies.set("session_token", "test-token") + + resp = client.get("/") + + assert resp.status_code == 503 + assert "make build-web" in resp.text + assert "pythinker_code/web/static/index.html" in resp.text + + +def test_vis_root_explains_missing_assets(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + import pythinker_code.vis.app as vis_app + + monkeypatch.setattr(vis_app, "STATIC_DIR", tmp_path) + with TestClient(vis_app.create_app()) as client: + resp = client.get("/") + + assert resp.status_code == 503 + assert "make build-vis" in resp.text