From 7502926e209513121129d2737a4e9110c1914a3d Mon Sep 17 00:00:00 2001 From: Mohamed Elkholy Date: Wed, 10 Jun 2026 19:37:38 -0400 Subject: [PATCH] fix: installer web UI 404 and PowerShell banner garbling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows and Linux native installers 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). Every PyInstaller spec aborts loudly when bundles are missing, and the apps serve an explanatory 503 page on `/` as a last-resort fallback instead of a bare 404. The startup banner raw-printed Unicode block art, which garbled on legacy Windows code pages (e.g. cp1252) and raised UnicodeEncodeError on redirected stdout. The banner now honors the existing ASCII-glyph detection with width-preserving fallbacks and degrades per line instead of crashing. Also fixes a latent TypeError in `print_banner` when all lines are `
`. --- .github/workflows/linux-installer.yml | 21 +++++ .github/workflows/windows-installer.yml | 19 +++++ CHANGELOG.md | 3 + packages/linux-installer/pythinker.spec | 22 +++++ packages/windows-installer/pythinker.spec | 22 +++++ pythinker.spec | 6 +- src/pythinker_code/utils/pyinstaller.py | 23 ++++++ src/pythinker_code/utils/server.py | 81 +++++++++++++++++-- src/pythinker_code/vis/app.py | 14 +++- src/pythinker_code/web/app.py | 20 ++++- tasks/todo.md | 58 ++++++++++++++ tests/utils/test_pyinstaller_utils.py | 21 +++++ tests/utils/test_server.py | 98 +++++++++++++++++++++++ tests/web/test_web_ui_assets.py | 39 +++++++++ 14 files changed, 436 insertions(+), 11 deletions(-) create mode 100644 tests/web/test_web_ui_assets.py 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