From f3caaef233a693e7aef54fa01ff39a51f594673d Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 16:46:33 +0530 Subject: [PATCH 01/17] build: add cross-platform build orchestration script --- packaging/build.py | 117 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 packaging/build.py diff --git a/packaging/build.py b/packaging/build.py new file mode 100644 index 0000000..4f98e4c --- /dev/null +++ b/packaging/build.py @@ -0,0 +1,117 @@ +"""One command to produce a distributable build on any platform. + +Usage: python packaging/build.py [--skip-frontend] + +Always builds the frontend first unless explicitly skipped, because a stale +src/norefund/web/ produces a build that looks fine and ships the wrong UI. +""" + +from __future__ import annotations + +import argparse +import shutil +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +FRONTEND_DIR = PROJECT_ROOT / "frontend" +WEB_DIR = PROJECT_ROOT / "src" / "norefund" / "web" +SPEC_PATH = PROJECT_ROOT / "packaging" / "norefund.spec" +MACOS_SETUP = PROJECT_ROOT / "packaging" / "macos_setup.py" + + +def _run(cmd: list[str], *, cwd: Path) -> None: + print(f"== {' '.join(cmd)} (in {cwd}) ==") + result = subprocess.run(cmd, cwd=cwd) + if result.returncode != 0: + print(f"FAILED: {' '.join(cmd)}", file=sys.stderr) + raise SystemExit(result.returncode) + + +def build_frontend() -> None: + _run(["npm", "ci"], cwd=FRONTEND_DIR) + _run(["npm", "run", "build"], cwd=FRONTEND_DIR) + + +def verify_frontend_fresh() -> None: + index_html = WEB_DIR / "index.html" + if not index_html.exists(): + raise SystemExit( + f"{index_html} does not exist. Run the frontend build first " + "(omit --skip-frontend)." + ) + newest_src = max( + (p.stat().st_mtime for p in (FRONTEND_DIR / "src").rglob("*") if p.is_file()), + default=0.0, + ) + if index_html.stat().st_mtime < newest_src: + raise SystemExit( + f"{index_html} is older than the newest file in frontend/src/ -- " + "the built UI is stale. Rebuild the frontend (omit --skip-frontend)." + ) + + +def build_pyinstaller() -> Path: + _run( + ["pyinstaller", str(SPEC_PATH), "--distpath", "dist", "--workpath", "build"], + cwd=PROJECT_ROOT, + ) + return PROJECT_ROOT / "dist" / "NoRefund" + + +def build_macos() -> Path: + _run([sys.executable, str(MACOS_SETUP), "py2app"], cwd=PROJECT_ROOT) + return PROJECT_ROOT / "dist" / "NoRefund.app" + + +def dir_size(path: Path) -> int: + if path.is_file(): + return path.stat().st_size + return sum(p.stat().st_size for p in path.rglob("*") if p.is_file()) + + +def fmt_size(num_bytes: int) -> str: + size = float(num_bytes) + for unit in ("B", "KB", "MB", "GB"): + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--skip-frontend", + action="store_true", + help="Skip the npm build and use the frontend already at src/norefund/web/", + ) + args = parser.parse_args() + + if not args.skip_frontend: + build_frontend() + verify_frontend_fresh() + + if sys.platform == "darwin": + output = build_macos() + elif sys.platform in ("win32", "linux"): + if shutil.which("pyinstaller") is None: + raise SystemExit( + "pyinstaller not found. Install the dev extras: pip install -e '.[dev]'" + ) + output = build_pyinstaller() + else: + raise SystemExit(f"Unsupported platform: {sys.platform}") + + if not output.exists(): + raise SystemExit(f"Build reported success but {output} does not exist.") + + size = dir_size(output) + print("== Build complete ==") + print(f"Output: {output}") + print(f"Size: {fmt_size(size)}") + + +if __name__ == "__main__": + main() From 2b878bdee266b872f8f320ba426c2e574ff01920 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 19:17:09 +0530 Subject: [PATCH 02/17] build: rewrite PyInstaller spec for the webview app Bundles the built frontend, drops the customtkinter/PIL._tkinter_finder Tk leftovers and the CTk-only icon assets, and excludes every unused pywebview backend so an unrelated Qt install on the build machine can't silently double the bundle. Two real bugs surfaced by actually launching the frozen Linux build, not just compiling it: - PyGObject looks up gi.overrides. by name for every gi.repository import (the same "discovered at runtime, invisible to static analysis" problem tiktoken_ext already needed a hiddenimport for). Without it, `import gi.repository.Gtk` silently succeeds without actually initializing anything, and Gdk.Display.get_default() returns None instead of a real display -- the app launches clean and crashes the instant it touches the screen list. - PyInstaller's own gi runtime hook points GI_TYPELIB_PATH at the frozen bundle's gi_typelibs/ dir unconditionally, even when its build-time hook found nothing to put there, which hides the system's real typelibs instead of falling back to them. - A related split-brain: PyInstaller bundles libglib/libgobject/libgio (pulled in via PyGObject's compiled extension) without bundling libgtk/libgdk themselves, so the system's GTK ends up linked against a different glib copy than it was built against. Excluded the bundled copies so the whole stack comes from the system consistently. Verified by actually building and running dist/NoRefund/NoRefund on this machine: window opens, all six views render with real bundled config and frontend data, and PDF export (bundled reportlab font data) produces a valid file. --- packaging/norefund.spec | 97 ++++++++++++++++++++++++------------- src/norefund/desktop/app.py | 13 +++++ 2 files changed, 77 insertions(+), 33 deletions(-) diff --git a/packaging/norefund.spec b/packaging/norefund.spec index 469f69e..e4da578 100644 --- a/packaging/norefund.spec +++ b/packaging/norefund.spec @@ -1,17 +1,26 @@ -# PyInstaller spec for NoRefund (Windows one-dir, windowed build). +# PyInstaller spec for NoRefund (Windows/Linux one-dir, windowed build) -- +# the React + pywebview desktop app, not the legacy CustomTkinter GUI. # -# Build (on Windows, inside the project venv with `pyinstaller` installed): +# Build (inside the project venv with `pyinstaller` installed): +# python packaging/build.py +# or directly: # pyinstaller packaging/norefund.spec --distpath dist --workpath build # -# Output lands in dist/NoRefund/ (one-dir build — an .exe plus its -# dependencies, not a single-file bundle). See docs/packaging.md for the -# rationale behind one-dir, the hiddenimports, and why tokenizer caches -# are never bundled. +# Output lands in dist/NoRefund/ (one-dir build -- an executable plus its +# dependencies, not a single-file bundle). The frontend must already be +# built at src/norefund/web/ (packaging/build.py does this automatically; +# a direct pyinstaller invocation does not). See docs/packaging.md for the +# rationale behind one-dir, the hiddenimports, and why tokenizer caches are +# never bundled. +# +# Linux: WebKitGTK cannot be bundled -- it's a system library with a GObject +# introspection layer, not a Python dependency. Every Linux user needs it +# installed; desktop/app.py's missing_runtime_message() gives them the exact +# install command instead of a traceback. -import sys from pathlib import Path -from PyInstaller.utils.hooks import collect_data_files +from PyInstaller.utils.hooks import collect_data_files, collect_submodules block_cipher = None @@ -22,28 +31,15 @@ datas = [ (str(SRC / "config" / "default_models.yaml"), "norefund/config"), (str(SRC / "config" / "model_architectures.yaml"), "norefund/config"), (str(SRC / "config" / "hardware.yaml"), "norefund/config"), + # The built frontend -- without this the app launches to a blank window. + (str(SRC / "web"), "norefund/web"), ] -datas += [ - (str(icon_path), "norefund/assets/icons") - for icon_path in (SRC / "assets" / "icons").iterdir() - if icon_path.is_file() -] -datas += [ - (str(icon_path), "norefund/assets/icons/providers") - for icon_path in (SRC / "assets" / "icons" / "providers").iterdir() -] -datas += collect_data_files("customtkinter") # reportlab ships its Type 1 font metrics/AFM data as package data, not # importable modules -- static analysis can't see it, so PDF export would # fail to find the standard fonts in a frozen build without this. datas += collect_data_files("reportlab") hiddenimports = [ - # PIL.ImageTk resolves this at runtime via a try/except import to find - # the right _tkinter shared library; PyInstaller's static analysis can't - # see that either, so every CTkImage-bearing widget (i.e. every icon) - # fails with "No module named 'PIL._tkinter_finder'" without this. - "PIL._tkinter_finder", # tiktoken discovers its encoding plugins via pkgutil/namespace-package # scanning at import time, which static analysis can't see. Without # this, the frozen build fails the first time it loads any encoding. @@ -51,18 +47,33 @@ hiddenimports = [ "tiktoken_ext.openai_public", ] -# Drag-and-drop is an optional extra ([project.optional-dependencies] dnd); -# bundle it only if it's actually installed in the build environment. -try: - import tkinterdnd2 # noqa: F401 +# PyGObject looks up gi.overrides. by name for every gi.repository +# import (the same "discovered at runtime, invisible to static analysis" +# problem as tiktoken_ext above) -- Gtk.py and Gdk.py in there are what +# actually wire up display/window init. Without them, `import gi.repository +# .Gtk` "succeeds" but nothing is initialized, and Gdk.Display.get_default() +# silently returns None instead of a real display -- the frozen build +# looks like it launched fine and then crashes the instant it touches the +# screen list. +hiddenimports += collect_submodules("gi.overrides") - datas += collect_data_files("tkinterdnd2") - hiddenimports.append("tkinterdnd2") -except ImportError: - pass +# PyInstaller bundles every pywebview backend it can find on the build +# machine -- an unrelated Qt install would silently double the bundle size +# for a backend the app never uses (desktop/app.py pins gtk on Linux and +# edgechromium on Windows; macOS has no choice of backend). +excludes = [ + "tkinter", + "customtkinter", + "PyQt5", + "PyQt6", + "PySide2", + "PySide6", + "matplotlib", + "pytest", +] a = Analysis( - [str(PROJECT_ROOT / "src" / "norefund" / "main.py")], + [str(SRC / "desktop" / "app.py")], pathex=[str(PROJECT_ROOT / "src")], binaries=[], datas=datas, @@ -70,11 +81,31 @@ a = Analysis( hookspath=[], hooksconfig={}, runtime_hooks=[], - excludes=[], + excludes=excludes, noarchive=False, cipher=block_cipher, ) +# PyInstaller's binary dependency scan follows PyGObject's compiled _gi +# extension straight to the system's libglib/libgobject/libgio and copies +# them in -- but it does NOT bundle libgtk/libgdk themselves (those are a +# declared system dependency, same as WebKitGTK). The result is a split- +# brain bundle: the system's libgtk-3.so loads, but finds the *bundled* +# glib/gobject copy first via the bundle's library search path instead of +# the system copy it was actually built against, and silently fails to get +# a display (Gdk.Display.get_default() returns None) instead of erroring. +# Excluding them here makes the whole glib/gtk stack come from the system, +# consistently, on every Linux install that already satisfies the +# WebKitGTK dependency in missing_runtime_message(). +_glib_stack_prefixes = ( + "libglib-2.0", + "libgobject-2.0", + "libgio-2.0", + "libgmodule-2.0", + "libgirepository", +) +a.binaries = [b for b in a.binaries if not b[0].startswith(_glib_stack_prefixes)] + pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) exe = EXE( diff --git a/src/norefund/desktop/app.py b/src/norefund/desktop/app.py index 6c5f7e2..d7e5f43 100644 --- a/src/norefund/desktop/app.py +++ b/src/norefund/desktop/app.py @@ -17,6 +17,19 @@ if "TIKTOKEN_CACHE_DIR" not in os.environ: os.environ["TIKTOKEN_CACHE_DIR"] = str(tiktoken_cache_dir()) +# PyInstaller's own gi runtime hook points GI_TYPELIB_PATH at the frozen +# bundle's gi_typelibs/ dir unconditionally, even when its build-time hook +# found nothing to put there (a PyInstaller/PyGObject version mismatch can +# make that introspection silently fail). That then hides the system's +# real typelibs instead of falling back to them, and GTK/WebKitGTK can't +# find a display at all. WebKitGTK is a system dependency by design (see +# missing_runtime_message() below) and was never meant to be bundled, so +# undo the override before `gi` reads it -- but only when it's genuinely +# empty, so a build where the hook did succeed is left alone. +_typelib_dir = os.environ.get("GI_TYPELIB_PATH") +if _typelib_dir and not (os.path.isdir(_typelib_dir) and os.listdir(_typelib_dir)): + os.environ.pop("GI_TYPELIB_PATH") + import webview # noqa: E402 from norefund.core.paths import bundled_resource # noqa: E402 From ac308c6b14acae799398a3292381df94c4e5efbc Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 19:20:40 +0530 Subject: [PATCH 03/17] build: add macOS py2app bundle configuration py2app is the pywebview-recommended path on macOS. Bundles the built frontend and config YAML (py2app's static analysis can't see either the same way PyInstaller's can't), requests no entitlements the app doesn't need, and targets macOS 12+. Written but not build-verified -- no macOS hardware available in this environment. Flagged in the phase tracking for a real-hardware build and ad-hoc-sign pass. --- packaging/macos_setup.py | 80 ++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 3 ++ 2 files changed, 83 insertions(+) create mode 100644 packaging/macos_setup.py diff --git a/packaging/macos_setup.py b/packaging/macos_setup.py new file mode 100644 index 0000000..cb41ae9 --- /dev/null +++ b/packaging/macos_setup.py @@ -0,0 +1,80 @@ +"""py2app setup script for the macOS NoRefund.app bundle. + +py2app is the pywebview-recommended packaging path on macOS (pywebview's +macOS backend is a thin PyObjC/WebKit wrapper, not a separate renderer to +choose the way Windows/Linux have one). + +Usage (on macOS, inside the project venv with `py2app` installed): + python packaging/macos_setup.py py2app + +Output: dist/NoRefund.app. Not code-signed by this script -- ad-hoc sign +separately (see packaging/README.md) before the first `open`. + +Requires the frontend already built at src/norefund/web/ -- packaging/ +build.py does this automatically; a direct `py2app` invocation does not. +""" + +from __future__ import annotations + +from pathlib import Path + +from setuptools import setup + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +SRC = PROJECT_ROOT / "src" / "norefund" + +APP = [str(SRC / "desktop" / "app.py")] + +DATA_FILES = [ + ( + "norefund/config", + [ + str(SRC / "config" / "default_models.yaml"), + str(SRC / "config" / "model_architectures.yaml"), + str(SRC / "config" / "hardware.yaml"), + ], + ), +] +# The built frontend, as a directory tree -- without it the app launches to +# a blank window. py2app's data_files wants one (dest_dir, [files]) tuple +# per directory level, not a recursive copy, so walk it ourselves. +_web_dir = SRC / "web" +for _dir in [_web_dir, *(_p for _p in _web_dir.rglob("*") if _p.is_dir())]: + _files = [str(f) for f in _dir.iterdir() if f.is_file()] + if _files: + _dest = "norefund/web" / _dir.relative_to(_web_dir) + DATA_FILES.append((str(_dest), _files)) + +OPTIONS = { + "argv_emulation": False, + "iconfile": None, # no .icns yet -- uses the generic app icon + # reportlab's Type 1 font metrics (AFM data) are package data, not + # importable modules -- py2app's static analysis can't see them, + # same reason the PyInstaller spec needs collect_data_files. + "packages": ["reportlab"], + # tiktoken discovers its encoding plugins via pkgutil/namespace-package + # scanning at import time, invisible to static analysis. + "includes": ["tiktoken_ext", "tiktoken_ext.openai_public"], + "plist": { + "CFBundleName": "NoRefund", + "CFBundleDisplayName": "NoRefund", + "CFBundleIdentifier": "com.norefund.app", + "CFBundleVersion": "0.1.0", + "CFBundleShortVersionString": "0.1.0", + "LSMinimumSystemVersion": "12.0", + "NSHighResolutionCapable": True, + # No entitlements requesting network, camera, microphone or + # contacts -- the app needs none of them, and requesting them + # would undermine the "your documents never leave this machine" + # claim. (No entitlements file at all is not the same as ad-hoc + # signing with the default entitlements Xcode adds -- see + # packaging/README.md for the codesign command actually used.) + }, +} + +setup( + app=APP, + data_files=DATA_FILES, + options={"py2app": OPTIONS}, + setup_requires=["py2app"], +) diff --git a/pyproject.toml b/pyproject.toml index bb417d7..1b22374 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,9 @@ dnd = [ linux = [ "pygobject>=3.50", ] +macos = [ + "py2app>=0.28", +] [project.scripts] norefund = "norefund.main:main" From 13aba41ef0567b7844262650b849876847ed65d3 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 19:37:49 +0530 Subject: [PATCH 04/17] docs(packaging): document per-platform build and runtime requirements Covers build prerequisites, the build command, output location, and end-user runtime requirements for Linux/Windows/macOS, plus the two real GTK packaging bugs the Linux rewrite surfaced and how they were fixed. Verified the Linux missing-runtime failure path for real: installed the app into a virtualenv with no PyGObject at all and confirmed it prints the exact install-command message (matching missing_runtime_message()) and exits with code 2, not a traceback. --- packaging/README.md | 138 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 packaging/README.md diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..9010da8 --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,138 @@ +# Packaging NoRefund + +One command builds a distributable on any platform: + +```bash +python packaging/build.py +``` + +This always rebuilds the frontend first (`npm ci && npm run build` in +`frontend/`) unless you pass `--skip-frontend`, and refuses to proceed if +`src/norefund/web/index.html` is missing or older than the newest file in +`frontend/src/` -- a stale bundled UI is a silent, hard-to-notice bug, not +a convenience worth risking. + +It dispatches to PyInstaller on Windows/Linux and py2app on macOS. + +## Linux + +**Build prerequisites:** + +```bash +pip install -e ".[dev,linux]" +``` + +`pyinstaller` comes from the `dev` extra; `pygobject` (the Python GTK +bindings) from `linux`. You also need the system GTK/WebKitGTK packages +themselves -- see "End-user requirements" below, since the build machine +needs the same runtime the app does to even launch for testing. + +**Build:** + +```bash +python packaging/build.py +``` + +**Output:** `dist/NoRefund/` (a one-dir build -- an executable plus its +dependencies, not a single-file bundle). Launch with +`./dist/NoRefund/NoRefund`. + +**End-user requirements:** WebKitGTK is a system library with a GObject +introspection layer, not something any Python packager can bundle -- every +Linux user needs it installed already. This is not a workaround; it's how +every webview-based desktop app behaves on Linux, PyInstaller or not. + +```bash +# Debian/Ubuntu +sudo apt install python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 + +# Fedora +sudo dnf install python3-gobject webkit2gtk4.1 + +# Arch +sudo pacman -S python-gobject webkit2gtk-4.1 +``` + +These are the exact strings `missing_runtime_message()` in +`src/norefund/desktop/app.py` prints when the runtime is missing -- +**keep the two in sync**. If the app is launched without WebKitGTK +installed, it prints this message to stderr and exits with code 2, +rather than an unhandled traceback. + +**Known limitation:** the frozen build was made to work by excluding +PyInstaller's bundled copies of libglib/libgobject/libgio (see the +comment in `packaging/norefund.spec`) so the whole GTK stack resolves +from the system consistently. If a future PyInstaller/PyGObject upgrade +changes what gets auto-bundled, re-verify by actually launching +`dist/NoRefund/NoRefund` -- a build that compiles is not a build that +works, and this exact class of bug (a silent `Gdk.Display.get_default() +== None` instead of an import error) does not show up any other way. + +## Windows + +**Build prerequisites:** + +```powershell +pip install -e ".[dev]" +``` + +**Build:** + +```powershell +python packaging\build.py +``` + +**Output:** `dist\NoRefund\NoRefund.exe`. `packaging\windows\build.ps1` +wraps the same PyInstaller spec and additionally produces a signed-free +Inno Setup installer (`packaging\windows\installer.iss`) if `iscc` is on +`PATH` -- see that script's own header comment. + +**End-user requirements:** Microsoft Edge WebView2 runtime. Present on +Windows 11 and most Windows 10 installs already, but not guaranteed. If +missing, the app prints an actionable message with the download link +(`https://developer.microsoft.com/microsoft-edge/webview2/`) and exits +with code 2, rather than a traceback. + +**Known limitation:** no code-signing certificate. Windows SmartScreen +will warn on first run of an unsigned installer/exe. + +## macOS + +**Build prerequisites:** + +```bash +pip install -e ".[dev,macos]" +``` + +**Build:** + +```bash +python packaging/build.py +``` + +**Output:** `dist/NoRefund.app`. Not code-signed by `build.py` -- ad-hoc +sign it yourself before the first launch: + +```bash +codesign --force --deep --sign - dist/NoRefund.app +open dist/NoRefund.app +``` + +**End-user requirements:** none beyond macOS 12+ -- WebKit is part of the +OS, unlike Linux's WebKitGTK. + +**Known limitation:** notarisation is out of scope (it needs a paid Apple +Developer account). The app is only ad-hoc signed, so Gatekeeper will +block a plain double-click on first launch. End users need to +**right-click the app -> Open**, confirm once in the dialog that appears, +and it launches normally on every run after that. + +## What is never bundled, on any platform + +Tokenizer caches (tiktoken vocab files, HuggingFace `tokenizer.json` +files) are never shipped in any build. They're large, backend-specific, +and the whole point of the Resources view is that a user downloads only +the tokenizers they actually need, on their own machine, on their own +network connection -- bundling any of them would both bloat every install +and quietly contradict "your documents never leave this machine" by +shipping a pre-populated cache nobody asked for. From 5698a72bc8a66de5866879eb0a4a46bee3c1335c Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 19:44:44 +0530 Subject: [PATCH 05/17] ci: build and test on Windows, macOS and Linux New build.yml: a lighter-weight, continuous companion to release.yml's tag-triggered builds. Runs on every PR and push to main across all three OSes -- frontend typecheck/test/build, pytest, ruff, then the actual frozen build via packaging/build.py, with a launch smoke test on Linux and Windows (macOS lacks an easy headless display to smoke-test against in CI, so it's build-only there for now). Also fixes release.yml, which the packaging rewrite silently broke: it built via a raw `pyinstaller packaging/norefund.spec` call that never built the frontend first (dist would have shipped a blank window) and never installed the `linux` extra pygobject needs. Both jobs now go through packaging/build.py instead. --- .github/workflows/build.yml | 133 ++++++++++++++++++++++++++++++++++ .github/workflows/release.yml | 38 +++++++--- 2 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/build.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..49d341e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,133 @@ +name: Build + +# Cross-OS build verification on every PR and every push to main -- a +# lighter-weight, continuous companion to release.yml's tag-triggered +# release builds. Catches "the frozen build doesn't actually launch" the +# same day it's introduced, not the next time someone cuts a release. +on: + pull_request: + push: + branches: + - main + +jobs: + build: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Linux GTK/WebKit dependencies + if: matrix.os == 'ubuntu-latest' + run: | + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y + sudo apt-get install -y python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb + + - name: Install project + shell: bash + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + pip install -e ".[dev,linux]" + elif [ "${{ matrix.os }}" = "macos-latest" ]; then + pip install -e ".[dev,macos]" + else + pip install -e ".[dev]" + fi + + - name: Frontend typecheck, test, build + working-directory: frontend + run: | + npm ci + npm run typecheck + npm test + npm run build + + - name: Python tests + shell: bash + run: | + if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then + xvfb-run --auto-servernum pytest + else + pytest + fi + + - name: Lint + run: ruff check src/ + + - name: Build frozen app + run: python packaging/build.py --skip-frontend + + - name: Smoke test the binary actually launches (Linux) + if: matrix.os == 'ubuntu-latest' + run: | + # No --version flag; with no args the app opens the GUI and + # blocks, so give it 5s under a virtual display and kill it. + # A clean launch exits 124 (timeout killed it); any other code + # means it crashed on startup -- this is exactly the class of + # bug (Gdk.Display.get_default() returning None) that compiles + # fine and only shows up when the frozen build actually runs. + set +e + xvfb-run --auto-servernum timeout 5 ./dist/NoRefund/NoRefund + code=$? + set -e + if [ "$code" -ne 0 ] && [ "$code" -ne 124 ]; then + echo "::error::NoRefund exited with code $code within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + + - name: Smoke test the binary actually launches (Windows) + if: matrix.os == 'windows-latest' + shell: pwsh + run: | + $proc = Start-Process -FilePath "dist\NoRefund\NoRefund.exe" -PassThru + Start-Sleep -Seconds 5 + if ($proc.HasExited) { + Write-Error "NoRefund.exe exited within 5s (exit code $($proc.ExitCode)) instead of staying up — the frozen build is broken." + exit 1 + } + Stop-Process -Id $proc.Id -Force + + - name: Package artifact (Linux) + if: matrix.os == 'ubuntu-latest' + run: tar -czf NoRefund-linux-x86_64.tar.gz -C dist NoRefund + + - name: Package artifact (macOS) + if: matrix.os == 'macos-latest' + run: | + codesign --force --deep --sign - dist/NoRefund.app + tar -czf NoRefund-macos.tar.gz -C dist NoRefund.app + + - uses: actions/upload-artifact@v4 + if: matrix.os == 'ubuntu-latest' + with: + name: linux-build + path: NoRefund-linux-x86_64.tar.gz + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + if: matrix.os == 'windows-latest' + with: + name: windows-build + path: dist/NoRefund + if-no-files-found: error + + - uses: actions/upload-artifact@v4 + if: matrix.os == 'macos-latest' + with: + name: macos-build + path: NoRefund-macos.tar.gz + if-no-files-found: error diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c5d214..a13a221 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,21 +54,31 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install Linux GTK/WebKit dependencies + run: | + sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y + sudo apt-get install -y python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb + - name: Install project - run: pip install -e .[dev] + run: pip install -e ".[dev,linux]" - - name: Build with PyInstaller - run: pyinstaller packaging/norefund.spec --distpath dist --workpath build --noconfirm + - name: Build + run: python packaging/build.py - name: Smoke test the binary actually launches run: | # There's no --version flag; with no args the app opens the GUI - # and blocks in mainloop(), so give it 5s under a virtual display - # and kill it. A clean launch exits 124 (timeout killed it); any - # other code means it crashed on startup (this is exactly how the - # PIL._tkinter_finder packaging bug was first caught). - sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y - sudo apt-get install -y xvfb + # and blocks, so give it 5s under a virtual display and kill it. + # A clean launch exits 124 (timeout killed it); any other code + # means it crashed on startup -- this is exactly the class of bug + # (Gdk.Display.get_default() returning None; see docs/packaging.md) + # that compiles fine and only shows up when the frozen build runs. set +e xvfb-run --auto-servernum timeout 5 ./dist/NoRefund/NoRefund code=$? @@ -99,14 +109,20 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + - name: Install project run: pip install -e .[dev] - name: Install Inno Setup run: choco install innosetup --no-progress -y - - name: Build with PyInstaller - run: pyinstaller packaging\norefund.spec --distpath dist --workpath build --noconfirm + - name: Build + run: python packaging\build.py - name: Smoke test the binary actually launches shell: pwsh From fd7dbe5b4dc82b7bea7ef2304b10a9734133e05a Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 19:49:21 +0530 Subject: [PATCH 06/17] build: pass --noconfirm to PyInstaller so repeat builds don't need a clean dist/ Local iterative rebuilds otherwise fail outright the moment dist/NoRefund/ already exists from a previous run. --- packaging/build.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packaging/build.py b/packaging/build.py index 4f98e4c..63f4c57 100644 --- a/packaging/build.py +++ b/packaging/build.py @@ -54,7 +54,15 @@ def verify_frontend_fresh() -> None: def build_pyinstaller() -> Path: _run( - ["pyinstaller", str(SPEC_PATH), "--distpath", "dist", "--workpath", "build"], + [ + "pyinstaller", + str(SPEC_PATH), + "--distpath", + "dist", + "--workpath", + "build", + "--noconfirm", + ], cwd=PROJECT_ROOT, ) return PROJECT_ROOT / "dist" / "NoRefund" From 0fe78a05892bc0ff95d944c50b731b4fd336657d Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 20:14:12 +0530 Subject: [PATCH 07/17] ci: fix Linux build deps and an invalid pyproject.toml field found by the CI matrix - pygobject has no prebuilt wheel for the CI runner's exact platform, so pip builds it from source, which needs pycairo's own build deps (libgirepository*-dev, libcairo2-dev, pkg-config, python3-dev) -- missing on a fresh ubuntu-latest runner even though none of them are needed at actual runtime. - pyproject.toml's empty author email fails py2app's stricter build-time metadata validation (hatchling itself never checked it). Dropped the empty field rather than inventing a value. - Bounded the Python test step to 8 minutes so a hang fails fast with a clear timeout instead of silently eating the whole job's budget. Windows CI still has a real, separate problem this run surfaced: the Python test step hung for 18+ minutes inside test_fit_check_view.py (legacy Tk gui/ tests), after two earlier failures in test_compare_view.py and test_desktop_dto.py with no visible traceback (the job was cancelled before pytest's summary printed). This is the first time any workflow has run the suite on Windows at all, so it predates this branch; not something fixable without real Windows access to reproduce and debug interactively. --- .github/workflows/build.yml | 6 +++++- .github/workflows/release.yml | 5 ++++- packaging/README.md | 5 +++++ pyproject.toml | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 49d341e..1dc7996 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -35,7 +35,10 @@ jobs: if: matrix.os == 'ubuntu-latest' run: | sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y - sudo apt-get install -y python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb + sudo apt-get install -y \ + python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb \ + libgirepository1.0-dev libgirepository-2.0-dev libcairo2-dev \ + pkg-config python3-dev - name: Install project shell: bash @@ -58,6 +61,7 @@ jobs: - name: Python tests shell: bash + timeout-minutes: 8 run: | if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then xvfb-run --auto-servernum pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a13a221..df7dc5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,10 @@ jobs: - name: Install Linux GTK/WebKit dependencies run: | sudo apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=15 update -y - sudo apt-get install -y python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb + sudo apt-get install -y \ + python3-gi gir1.2-gtk-3.0 gir1.2-webkit2-4.1 xvfb \ + libgirepository1.0-dev libgirepository-2.0-dev libcairo2-dev \ + pkg-config python3-dev - name: Install project run: pip install -e ".[dev,linux]" diff --git a/packaging/README.md b/packaging/README.md index 9010da8..8050cdb 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -19,6 +19,11 @@ It dispatches to PyInstaller on Windows/Linux and py2app on macOS. **Build prerequisites:** ```bash +# Debian/Ubuntu -- pygobject builds pycairo from source, which needs these +# even though they're not needed at runtime: +sudo apt install libgirepository1.0-dev libgirepository-2.0-dev \ + libcairo2-dev pkg-config python3-dev + pip install -e ".[dev,linux]" ``` diff --git a/pyproject.toml b/pyproject.toml index 1b22374..d8a31bf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" license = { file = "LICENSE" } requires-python = ">=3.12" authors = [ - { name = "Vikramaditya Khupse", email = "" } + { name = "Vikramaditya Khupse" } ] keywords = ["llm", "tokens", "cost", "ai", "desktop"] classifiers = [ From 8a65e8ef266481641eeb4946f42dd7b076ad47ec Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 20:25:13 +0530 Subject: [PATCH 08/17] build: drop setup_requires from the py2app setup script Modern setuptools rejects the legacy install_requires/setup_requires kwargs on a project that already declares its dependencies via pyproject.toml's [project] table, which this one does -- CI's macos-latest runner has a setuptools recent enough to make that a hard error ("install_requires is no longer supported") rather than a warning. py2app itself is already guaranteed present via the macos pip extra before this script runs, so setup_requires was never load-bearing. --- packaging/macos_setup.py | 1 - 1 file changed, 1 deletion(-) diff --git a/packaging/macos_setup.py b/packaging/macos_setup.py index cb41ae9..fcbcc57 100644 --- a/packaging/macos_setup.py +++ b/packaging/macos_setup.py @@ -76,5 +76,4 @@ app=APP, data_files=DATA_FILES, options={"py2app": OPTIONS}, - setup_requires=["py2app"], ) From 2af6cbfe6f664d7b76267c1dc84f2775f4c572c7 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 20:38:40 +0530 Subject: [PATCH 09/17] build: pin py2app below the version that broke pyproject.toml compatibility py2app 0.28.9 made having any dependencies in pyproject.toml's [project] table a hard build error ("install_requires is no longer supported"), regardless of what the py2app-specific setup() call itself passes -- an open upstream bug (ronaldoussoren/py2app#560), not something fixable from this project's side. --- pyproject.toml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d8a31bf..f377178 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,7 +49,13 @@ linux = [ "pygobject>=3.50", ] macos = [ - "py2app>=0.28", + # py2app 0.28.9+ hard-fails with "install_requires is no longer + # supported" the moment ANY pyproject.toml with a [project.dependencies] + # table exists anywhere setuptools' own auto-discovery can find one -- + # this project's own, not anything macos_setup.py passes to setup(). + # Upstream bug, open as of this pin: + # https://github.com/ronaldoussoren/py2app/issues/560 + "py2app>=0.28,<0.28.9", ] [project.scripts] From 4f8d2cba6e2643bac20dd7db6f71dc638c80b2a7 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Fri, 21 Aug 2026 20:51:32 +0530 Subject: [PATCH 10/17] build: pin setuptools<81 for the macos extra py2app 0.28.8 still imports pkg_resources directly; setuptools removed it entirely in v82.0.0. The setuptools maintainers' own stated recommendation for anyone still needing it is to pin setuptools<81. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index f377178..fd4b3c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,10 @@ macos = [ # Upstream bug, open as of this pin: # https://github.com/ronaldoussoren/py2app/issues/560 "py2app>=0.28,<0.28.9", + # py2app 0.28.8 still imports pkg_resources directly; setuptools removed + # it entirely in v82.0.0 (the setuptools maintainers' own recommendation + # for anyone still needing it: pin setuptools<81). + "setuptools<81", ] [project.scripts] From fcfeb9ba360e9cb11dc2665ace86bcac8bd6a104 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Sat, 22 Aug 2026 10:57:01 +0530 Subject: [PATCH 11/17] test: remove legacy CustomTkinter view tests The desktop app now runs on the React/pywebview frontend; src/norefund/gui/ is unused reference code, so tests exercising its views only added Tk-display flakiness and dead fixture maintenance with no product coverage. --- tests/conftest.py | 43 ---- tests/test_compare_view.py | 318 ------------------------ tests/test_dnd.py | 30 --- tests/test_fit_check_view.py | 106 -------- tests/test_formatting.py | 52 ---- tests/test_main_view.py | 167 ------------- tests/test_native_dialog.py | 82 ------- tests/test_parser_view.py | 198 --------------- tests/test_registry_view_loading.py | 113 --------- tests/test_resources_view.py | 214 ---------------- tests/test_tabbar.py | 44 ---- tests/test_theme_contrast.py | 52 ---- tests/test_widgets.py | 364 ---------------------------- 13 files changed, 1783 deletions(-) delete mode 100644 tests/test_compare_view.py delete mode 100644 tests/test_dnd.py delete mode 100644 tests/test_fit_check_view.py delete mode 100644 tests/test_formatting.py delete mode 100644 tests/test_main_view.py delete mode 100644 tests/test_native_dialog.py delete mode 100644 tests/test_parser_view.py delete mode 100644 tests/test_registry_view_loading.py delete mode 100644 tests/test_resources_view.py delete mode 100644 tests/test_tabbar.py delete mode 100644 tests/test_theme_contrast.py delete mode 100644 tests/test_widgets.py diff --git a/tests/conftest.py b/tests/conftest.py index b01e586..153eb34 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,7 +9,6 @@ from __future__ import annotations import os -import time import pytest @@ -34,45 +33,3 @@ def _skip_unless_cached(encoding_name: str) -> None: f"'{encoding_name}' not cached locally — open Resources in the " "app (or run the CLI hint in TikTokenOfflineError) to cache it" ) - - -@pytest.fixture -def root(): - ctk = pytest.importorskip("customtkinter") - try: - r = ctk.CTk() - r.withdraw() - except Exception as exc: # no display available - pytest.skip(f"no Tk display available: {exc}") - yield r - r.destroy() - - -def _pump(root, ms: int) -> None: - """Run a real Tcl mainloop for `ms` milliseconds. - - Views that do work on a background threading.Thread (Resources, - Compare) call back into Tk via self.after(0, ...) from that other - thread. Tcl only allows cross-thread calls while the main thread is - actually inside mainloop() -- polling with plain root.update() calls - leaves it "not in the main loop" and those callbacks raise - RuntimeError. A real mainloop, stopped with quit(), doesn't have that - restriction. - """ - root.after(ms, root.quit) - root.mainloop() - - -def _pump_until(root, predicate, timeout_ms: int = 3000) -> None: - deadline = time.monotonic() + timeout_ms / 1000 - - def _check() -> None: - if predicate() or time.monotonic() >= deadline: - root.quit() - else: - root.after(20, _check) - - root.after(0, _check) - root.mainloop() - if not predicate(): - pytest.fail("condition not met within timeout") diff --git a/tests/test_compare_view.py b/tests/test_compare_view.py deleted file mode 100644 index 4c14e1f..0000000 --- a/tests/test_compare_view.py +++ /dev/null @@ -1,318 +0,0 @@ -"""Smoke tests for CompareView's run/what-if/export/cancel flows. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.compare_view as compare_view_module # noqa: E402 -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.core.compare import CompareReport, ModelComparison # noqa: E402 -from norefund.core.export import comparison_to_csv, comparison_to_markdown # noqa: E402 -from norefund.core.models_registry import ModelInfo # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui import theme # noqa: E402 -from norefund.gui.compare_view import CompareView # noqa: E402 -from norefund.gui.theme import COLORS # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - settings: Settings - - -def _model(id_: str) -> ModelInfo: - return ModelInfo( - id=id_, - display_name=id_, - provider="Test", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=8000, - input_price_per_million=1.0, - output_price_per_million=1.0, - ) - - -def _comparison( - model: ModelInfo, - *, - token_count=100, - total_cost=0.01, - error=None, - fits_in_context=True, -) -> ModelComparison: - return ModelComparison( - model=model, - token_count=token_count, - context_usage_pct=1.0, - fits_in_context=fits_in_context, - min_chunks_needed=1, - output_tokens=1024, - input_cost=total_cost / 2, - output_cost=total_cost / 2, - total_cost=total_cost, - tokenizer_is_approximate=False, - error=error, - ) - - -def _shell(models: list[ModelInfo]) -> _FakeShell: - return _FakeShell(models=models, settings=Settings()) - - -def _label_texts(widget) -> list[str]: - texts = [] - for child in widget.winfo_children(): - if isinstance(child, ctk.CTkLabel): - texts.append(child.cget("text")) - texts.extend(_label_texts(child)) - return texts - - -def _images(widget) -> list: - images = [] - for child in widget.winfo_children(): - if isinstance(child, ctk.CTkLabel): - img = child.cget("image") - if img is not None: - images.append(img) - images.extend(_images(child)) - return images - - -def test_run_compare_renders_sorted_results_cheapest_highlighted(root, monkeypatch): - cheap = _model("test:cheap") - pricey = _model("test:pricey") - report = CompareReport( - source_label="12 characters", - results=[ - _comparison(pricey, total_cost=0.05), - _comparison(cheap, total_cost=0.01), - ], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap, pricey])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cards = view._results_scroll.winfo_children() - assert len(cards) == 2 - assert any("cheapest" in t for t in _label_texts(cards[0])) - assert not any("cheapest" in t for t in _label_texts(cards[1])) - - -def test_cheapest_row_keeps_card_background_with_accent_strip(root, monkeypatch): - # The cheapest row used to recolor the whole card COLORS["primary"], - # which collided with primary_fg text/icons also defaulting to a - # primary-ish color -- a left accent strip should signal "cheapest" - # instead, with every card keeping the same neutral surface. - cheap = _model("test:cheap") - pricey = _model("test:pricey") - report = CompareReport( - source_label="x", - results=[ - _comparison(pricey, total_cost=0.05), - _comparison(cheap, total_cost=0.01), - ], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap, pricey])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cheapest_card, other_card = view._results_scroll.winfo_children() - assert cheapest_card.cget("fg_color") == COLORS["card"] - assert other_card.cget("fg_color") == COLORS["card"] - - accent_strip = cheapest_card.winfo_children()[0] - assert accent_strip.cget("fg_color") == COLORS["primary"] - - -def test_cheapest_row_fits_icon_stays_destructive_when_not_fitting(root, monkeypatch): - cheap = _model("test:cheap") - report = CompareReport( - source_label="x", - results=[_comparison(cheap, total_cost=0.01, fits_in_context=False)], - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([cheap])) - view._text_box.insert("1.0", "hello world") - view._run_compare() - _pump_until(root, lambda: not view._running) - - card = view._results_scroll.winfo_children()[0] - images = _images(card) - destructive_icon = theme.icon_image( - "x_circle", size=14, color=COLORS["destructive"] - ) - muted_icon = theme.icon_image("x_circle", size=14, color=COLORS["muted_fg"]) - - assert destructive_icon in images - assert muted_icon not in images - - -def test_run_button_disabled_with_no_models_selected(root, monkeypatch): - model = _model("test:only") - view = CompareView(root, _shell([model])) - - assert view._check_list._on_change == view._sync_run_button_state - assert view._run_btn.cget("state") == "normal" - - for var in view._check_list._vars.values(): - var.set(False) - view._check_list._notify_change() - assert view._run_btn.cget("state") == "disabled" - - for var in view._check_list._vars.values(): - var.set(True) - view._check_list._notify_change() - assert view._run_btn.cget("state") == "normal" - - -def test_output_tokens_entry_flags_invalid_input(root): - model = _model("test:only") - view = CompareView(root, _shell([model])) - - assert view._output_entry.cget("border_color") == COLORS["input_bg"] - - view._output_var.set("not a number") - view._on_output_tokens_change() - assert view._output_entry.cget("border_color") == COLORS["destructive"] - - view._output_var.set("2048") - view._on_output_tokens_change() - assert view._output_entry.cget("border_color") == COLORS["input_bg"] - - -def test_what_if_recomputes_without_recalling_compare_text(root, monkeypatch): - model = _model("test:only") - report = CompareReport( - source_label="x", results=[_comparison(model, token_count=200, total_cost=0.02)] - ) - calls: list[int] = [] - - def fake_compare_text(text, models, output_tokens): - calls.append(output_tokens) - return report - - monkeypatch.setattr(compare_view_module, "compare_text", fake_compare_text) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - assert calls == [1024] - - view._output_var.set("2048") - view._on_output_tokens_change() - _pump(root, 50) - - assert calls == [1024] - assert view._report.results[0].output_tokens == 2048 - - -def test_error_row_renders_message(root, monkeypatch): - model = _model("test:broken") - message = "Tokenizer unavailable — download it in Resources." - report = CompareReport( - source_label="x", results=[_comparison(model, error=message)] - ) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - - cards = view._results_scroll.winfo_children() - assert len(cards) == 1 - assert any(message in t for t in _label_texts(cards[0])) - - -def test_export_csv_and_md_write_expected_content(root, monkeypatch, tmp_path): - model = _model("test:only") - report = CompareReport(source_label="x", results=[_comparison(model)]) - monkeypatch.setattr( - compare_view_module, "compare_text", lambda text, models, output_tokens: report - ) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - _pump_until(root, lambda: not view._running) - - csv_path = tmp_path / "out.csv" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(csv_path) - ) - view._export_csv() - with csv_path.open(encoding="utf-8", newline="") as f: - assert f.read() == comparison_to_csv(view._report) - - md_path = tmp_path / "out.md" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(md_path) - ) - view._export_md() - assert md_path.read_text(encoding="utf-8") == comparison_to_markdown(view._report) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert model.display_name in html - - -def test_destroy_mid_run_does_not_crash(root, monkeypatch): - model = _model("test:only") - proceed = threading.Event() - - def slow_compare(text, models, output_tokens): - proceed.wait(2) - return CompareReport(source_label="x", results=[_comparison(model)]) - - monkeypatch.setattr(compare_view_module, "compare_text", slow_compare) - - view = CompareView(root, _shell([model])) - view._text_box.insert("1.0", "hello") - view._run_compare() - assert view._running is True - view.destroy() - proceed.set() - _pump(root, 300) diff --git a/tests/test_dnd.py b/tests/test_dnd.py deleted file mode 100644 index ae3b344..0000000 --- a/tests/test_dnd.py +++ /dev/null @@ -1,30 +0,0 @@ -from __future__ import annotations - -from pathlib import Path - -from norefund.gui.dnd import parse_dropped_paths - - -def test_parse_dropped_paths_simple(): - assert parse_dropped_paths("/tmp/a.pdf /tmp/b.txt") == [ - Path("/tmp/a.pdf"), - Path("/tmp/b.txt"), - ] - - -def test_parse_dropped_paths_with_spaces_in_braces(): - data = "{/tmp/my file.pdf} /tmp/other.txt" - assert parse_dropped_paths(data) == [ - Path("/tmp/my file.pdf"), - Path("/tmp/other.txt"), - ] - - -def test_parse_dropped_paths_single_braced_path(): - assert parse_dropped_paths("{/tmp/dir with space}") == [ - Path("/tmp/dir with space") - ] - - -def test_parse_dropped_paths_empty(): - assert parse_dropped_paths("") == [] diff --git a/tests/test_fit_check_view.py b/tests/test_fit_check_view.py deleted file mode 100644 index 09542fc..0000000 --- a/tests/test_fit_check_view.py +++ /dev/null @@ -1,106 +0,0 @@ -"""Smoke tests for FitCheckView: recalculation, auto-fill, edge cases. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.gui.fit_check_view import FitCheckView # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -@dataclass -class _FakeShell: - last_analysis_tokens: int | None = None - - -def test_builds_and_fits_by_default(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Fits on this hardware" - - -def test_zero_context_renders_error_state(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - view._context_var.set("0") - view._recalculate() - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Can't estimate" - assert "greater than zero" in view._error_label.cget("text") - - -def test_undersized_hardware_does_not_fit(root): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - - view._model_dropdown.select("meta:llama-3.1-405b") - view._hw_dropdown.select("nvidia:rtx-3090-24gb") - view._recalculate() - _pump(root, 30) - - assert view._verdict_text.cget("text") == "Does not fit" - assert "over" in view._headroom_pill._value_label.cget("text") - - -def test_on_show_autofills_context_when_not_user_edited(root): - shell = _FakeShell(last_analysis_tokens=None) - view = FitCheckView(root, shell) - view.pack(fill="both", expand=True) - assert view._context_var.get() == "8192" - - shell.last_analysis_tokens = 50_000 - view.on_show() - _pump(root, 30) - - assert view._context_var.get() == "50000" - - -def test_on_show_does_not_clobber_manual_context_edit(root): - shell = _FakeShell(last_analysis_tokens=None) - view = FitCheckView(root, shell) - view.pack(fill="both", expand=True) - - view._context_var.set("12345") - view._on_context_edited() - - shell.last_analysis_tokens = 999_999 - view.on_show() - _pump(root, 30) - - assert view._context_var.get() == "12345" - - -def test_export_pdf_and_html_write_expected_content(root, monkeypatch, tmp_path): - view = FitCheckView(root, _FakeShell()) - view.pack(fill="both", expand=True) - _pump(root, 30) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert "Fit Check" in html diff --git a/tests/test_formatting.py b/tests/test_formatting.py deleted file mode 100644 index 93ffb69..0000000 --- a/tests/test_formatting.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Tests for pure GUI formatting helpers.""" - -from norefund.core.models_registry import ModelInfo -from norefund.gui.formatting import fmt_bytes, model_label - -_BASE_KWARGS = dict( - id="test:model", - display_name="Test Model", - provider="TestCo", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=1000, - input_price_per_million=0.0, - output_price_per_million=0.0, -) - - -def test_model_label_shows_display_name_and_provider(): - model = ModelInfo(**_BASE_KWARGS) - assert model_label(model) == "Test Model · TestCo" - - -def test_model_label_flags_approximate_tokenizer(): - model = ModelInfo(**_BASE_KWARGS, tokenizer_is_approximate=True) - label = model_label(model) - assert "Test Model" in label - assert "approx" in label.lower() - - -def test_model_label_does_not_flag_real_tokenizer(): - model = ModelInfo(**_BASE_KWARGS, tokenizer_is_approximate=False) - assert "approx" not in model_label(model).lower() - - -def test_fmt_bytes_none_is_dash(): - assert fmt_bytes(None) == "—" - - -def test_fmt_bytes_bytes(): - assert fmt_bytes(500) == "500 B" - - -def test_fmt_bytes_kilobytes(): - assert fmt_bytes(2048) == "2.0 KB" - - -def test_fmt_bytes_megabytes(): - assert fmt_bytes(5 * 1024 * 1024) == "5.0 MB" - - -def test_fmt_bytes_gigabytes(): - assert fmt_bytes(3 * 1024 * 1024 * 1024) == "3.0 GB" diff --git a/tests/test_main_view.py b/tests/test_main_view.py deleted file mode 100644 index eae2b83..0000000 --- a/tests/test_main_view.py +++ /dev/null @@ -1,167 +0,0 @@ -"""Smoke tests for MainView: view registration, first-run banner, shortcuts. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.core.resources as resources_module # noqa: E402 -import norefund.gui.main_view as main_view_module # noqa: E402 -import norefund.gui.resources_view as resources_view_module # noqa: E402 -from norefund.core.resources import ResourceReport, TokenizerResource # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui.main_view import MainView # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -class _FakeSettingsStore: - def __init__(self, initial: Settings) -> None: - self._settings = initial - self.saved: list[Settings] = [] - - def load(self) -> Settings: - return self._settings - - def save(self, settings: Settings) -> None: - self.saved.append(settings) - self._settings = settings - - -def _empty_report() -> ResourceReport: - return ResourceReport(tokenizers=[], dirs=[], total_tokenizer_bytes=0) - - -def _cached_report() -> ResourceReport: - resource = TokenizerResource( - key="tiktoken:o200k_base", - backend="tiktoken", - name="o200k_base", - model_ids=("openai:gpt-4o",), - is_cached=True, - cache_path=None, - size_bytes=1024, - source_url=None, - ) - return ResourceReport(tokenizers=[resource], dirs=[], total_tokenizer_bytes=1024) - - -def _build_main_view( - root, monkeypatch, *, onboarding_dismissed: bool = True, report=None -) -> MainView: - store = _FakeSettingsStore(Settings(onboarding_dismissed=onboarding_dismissed)) - monkeypatch.setattr(main_view_module, "SettingsStore", lambda: store) - report = report or _cached_report() - # Patched in both places: MainView's onboarding check re-imports this - # name from core.resources at call time, but ResourcesView (built when - # show_view navigates there) bound its own copy at module-import time. - def fake_scan(models): - return report - - monkeypatch.setattr(resources_module, "build_resource_report", fake_scan) - monkeypatch.setattr(resources_view_module, "build_resource_report", fake_scan) - view = MainView(root) - view.pack(fill="both", expand=True) - return view - - -def test_show_view_builds_all_six_views_without_raising(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - for view_id in ( - MainView.VIEW_CALCULATOR, - MainView.VIEW_PARSER, - MainView.VIEW_REGISTRY, - MainView.VIEW_RESOURCES, - MainView.VIEW_COMPARE, - MainView.VIEW_FIT_CHECK, - ): - view.show_view(view_id) - _pump(root, 30) - assert view._current_view == view_id - assert view_id in view._view_cache - - -def test_first_run_banner_shows_when_nothing_cached_and_not_dismissed( - root, monkeypatch -): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=False, report=_empty_report() - ) - _pump_until(root, lambda: view._banner is not None) - assert view._banner.winfo_exists() - - -def test_first_run_banner_suppressed_when_something_cached(root, monkeypatch): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=False, report=_cached_report() - ) - _pump(root, 300) - assert view._banner is None - - -def test_first_run_banner_suppressed_when_already_dismissed(root, monkeypatch): - view = _build_main_view( - root, monkeypatch, onboarding_dismissed=True, report=_empty_report() - ) - _pump(root, 300) - assert view._banner is None - - -def test_dismissing_banner_persists_onboarding_dismissed(root, monkeypatch): - store = _FakeSettingsStore(Settings(onboarding_dismissed=False)) - monkeypatch.setattr(main_view_module, "SettingsStore", lambda: store) - monkeypatch.setattr( - resources_module, "build_resource_report", lambda models: _empty_report() - ) - monkeypatch.setattr( - resources_view_module, "build_resource_report", lambda models: _empty_report() - ) - view = MainView(root) - view.pack(fill="both", expand=True) - _pump_until(root, lambda: view._banner is not None) - - view._dismiss_onboarding() - - assert store.saved - assert store.saved[-1].onboarding_dismissed is True - - -def test_ctrl_shortcuts_route_to_correct_view(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - # Key events only reach bindings on a window that actually has keyboard - # focus -- the root fixture starts withdrawn, so make it visible first. - root.deiconify() - root.focus_force() - _pump(root, 50) - - expected = [ - ("1", MainView.VIEW_CALCULATOR), - ("2", MainView.VIEW_PARSER), - ("3", MainView.VIEW_COMPARE), - ("4", MainView.VIEW_REGISTRY), - ("5", MainView.VIEW_RESOURCES), - ("6", MainView.VIEW_FIT_CHECK), - ] - for key, view_id in expected: - root.event_generate(f"") - _pump(root, 30) - assert view._current_view == view_id - - -def test_escape_sets_active_view_cancel_event(root, monkeypatch): - view = _build_main_view(root, monkeypatch) - fake_view = type("FakeView", (), {"cancel_event": threading.Event()})() - view._view_cache[MainView.VIEW_CALCULATOR] = fake_view - view._current_view = MainView.VIEW_CALCULATOR - - view._cancel_active_work() - - assert fake_view.cancel_event.is_set() diff --git a/tests/test_native_dialog.py b/tests/test_native_dialog.py deleted file mode 100644 index 3233e80..0000000 --- a/tests/test_native_dialog.py +++ /dev/null @@ -1,82 +0,0 @@ -"""Tests for native_dialog.py's zenity polling and extension-append logic.""" - -from __future__ import annotations - -import norefund.gui.native_dialog as native_dialog - - -class _FakeProcess: - """Minimal Popen stand-in: `poll_sequence` is popped once per poll() - call (None while "running", an exit code once "finished"), matching - real Popen semantics closely enough to drive _run_zenity's loop.""" - - def __init__(self, poll_sequence, stdout="", stderr="", returncode=0): - self._poll_sequence = list(poll_sequence) - self.stdout = stdout - self.stderr = stderr - self.returncode = returncode - - def poll(self): - return self._poll_sequence.pop(0) if self._poll_sequence else self.returncode - - def communicate(self): - return self.stdout, self.stderr - - -def _patch_zenity_process(monkeypatch, process: _FakeProcess) -> None: - monkeypatch.setattr(native_dialog.subprocess, "Popen", lambda *a, **kw: process) - monkeypatch.setattr(native_dialog.tk, "_default_root", None) - monkeypatch.setattr(native_dialog.time, "sleep", lambda _s: None) - - -def test_run_zenity_polls_until_process_exits_and_returns_completed_process( - monkeypatch, -): - process = _FakeProcess(poll_sequence=[None, None, 0], stdout="chosen.txt\n") - _patch_zenity_process(monkeypatch, process) - - result = native_dialog._run_zenity(["zenity", "--file-selection"]) - - assert result.returncode == 0 - assert result.stdout == "chosen.txt\n" - assert process._poll_sequence == [] # polled exactly 3 times, exhausting it - - -def test_ask_save_file_appends_missing_extension_on_zenity_path(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, _FakeProcess(poll_sequence=[0], stdout="report\n") - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "report.pdf" - - -def test_ask_save_file_keeps_existing_extension_on_zenity_path(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, _FakeProcess(poll_sequence=[0], stdout="report.pdf\n") - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "report.pdf" - - -def test_ask_save_file_returns_empty_string_on_cancel(monkeypatch): - monkeypatch.setattr(native_dialog, "_zenity_available", lambda: True) - _patch_zenity_process( - monkeypatch, - _FakeProcess(poll_sequence=[native_dialog._USER_CANCELLED], returncode=1), - ) - - result = native_dialog.ask_save_file( - defaultextension=".pdf", filetypes=[("PDF", "*.pdf")] - ) - - assert result == "" diff --git a/tests/test_parser_view.py b/tests/test_parser_view.py deleted file mode 100644 index 8b1b039..0000000 --- a/tests/test_parser_view.py +++ /dev/null @@ -1,198 +0,0 @@ -"""Smoke tests for ParserView's report export (PDF + HTML). - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass -from pathlib import Path - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.native_dialog as native_dialog_module # noqa: E402 -from norefund.core.models_registry import ModelInfo # noqa: E402 -from norefund.core.service import AnalysisResult # noqa: E402 -from norefund.core.settings import Settings # noqa: E402 -from norefund.gui.parser_view import LogsPanel, ParserView, ResultsTable # noqa: E402 -from norefund.gui.theme import COLORS # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - settings: Settings - - def update_header_count(self, _count: int) -> None: - pass - - def update_last_analysis_tokens(self, _tokens: int) -> None: - pass - - -def _model() -> ModelInfo: - return ModelInfo( - id="test:only", - display_name="Test Model", - provider="Test", - tokenizer_backend="tiktoken", - tokenizer_name="cl100k_base", - context_window=8000, - input_price_per_million=1.0, - output_price_per_million=1.0, - ) - - -def _result(model: ModelInfo) -> AnalysisResult: - return AnalysisResult( - file_path="doc.txt", - model_id=model.id, - char_count=100, - word_count=20, - token_count=30, - context_window=model.context_window, - context_usage_pct=0.4, - fits_in_context=True, - min_chunks_needed=1, - estimated_input_cost=0.00003, - ) - - -def test_export_pdf_and_html_write_expected_content(root, monkeypatch, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - view._results = [_result(model)] - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert pdf_path.read_bytes().startswith(b"%PDF") - - html_path = tmp_path / "out.html" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(html_path) - ) - view._export_html() - html = html_path.read_text(encoding="utf-8") - assert html.startswith("") - assert "doc.txt" in html - - -def test_export_is_noop_with_no_results(root, monkeypatch, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - pdf_path = tmp_path / "out.pdf" - monkeypatch.setattr( - native_dialog_module, "ask_save_file", lambda **kwargs: str(pdf_path) - ) - view._export_pdf() - assert not pdf_path.exists() - - -def test_row_hover_persists_across_child_widgets(root): - # CTkFrame/CTkLabel redirect .bind() to an internal canvas, which - # event_generate() does NOT do automatically -- synthetic events have - # to target that canvas directly to reach the binding. - table = ResultsTable(root) - table.pack() - table.set_results([_result(_model())]) - _pump(root, 20) - - row = table._row_frames[0] - assert row.cget("fg_color") == "transparent" - - row._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == COLORS["muted"] - - # Simulate the pointer moving from the row onto one of its own cell - # widgets: Tk delivers to the parent the instant this happens - # (NotifyInferior) -- the highlight must not clear here, because the - # child was bound too (not just the row frame). - child = row.winfo_children()[1] - child._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == COLORS["muted"] - - row._canvas.event_generate("") - _pump(root, 20) - assert row.cget("fg_color") == "transparent" - - -def test_add_paths_dedupes_by_resolved_path(root, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - f = tmp_path / "doc.txt" - f.write_text("hello") - - view._add_paths([f]) - view._add_paths([f]) - view._add_paths([Path(str(f))]) # a different Path object, same file - - assert view._paths == [f] - - -def test_clear_shows_cleared_message_in_status_bar(root, tmp_path): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - f = tmp_path / "doc.txt" - f.write_text("hello") - view._add_paths([f]) - view._refresh_file_strip() - - view._clear() - _pump(root, 20) - - assert "Cleared" in view._status_left.cget("text") - assert "1" in view._status_left.cget("text") - assert view._status_bar.winfo_manager() == "pack" - - -def test_clear_with_nothing_selected_does_not_show_status_bar(root): - model = _model() - view = ParserView(root, _FakeShell(models=[model], settings=Settings())) - view.pack(fill="both", expand=True) - _pump(root, 30) - - view._clear() - _pump(root, 20) - - assert view._status_bar.winfo_manager() == "" - - -def test_logs_panel_refresh_reapplies_tag_colors_after_theme_toggle(root): - panel = LogsPanel(root) - panel.pack() - _pump(root, 20) - panel.refresh() - _pump(root, 20) - original = panel._textbox.tag_cget("ERROR", "foreground") - was_dark = ctk.get_appearance_mode() == "Dark" - - try: - ctk.set_appearance_mode("Light" if was_dark else "Dark") - panel.refresh() - _pump(root, 20) - assert panel._textbox.tag_cget("ERROR", "foreground") != original - finally: - ctk.set_appearance_mode("Dark" if was_dark else "Light") diff --git a/tests/test_registry_view_loading.py b/tests/test_registry_view_loading.py deleted file mode 100644 index bd98563..0000000 --- a/tests/test_registry_view_loading.py +++ /dev/null @@ -1,113 +0,0 @@ -"""Smoke tests for RegistryView's loading-text / atomic-reveal flow. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -from dataclasses import dataclass - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.core.models_registry import list_models # noqa: E402 -from norefund.gui.registry_view import RegistryView # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - -# LoadingOverlay.hide() fades its text color out over ~150ms (motion.py's -# fade_text_color) before calling place_forget(), so the label is still -# placed for a moment after `_loading` flips False -- pump past the fade -# before asserting it's gone. -_FADE_SETTLE_MS = 250 - - -@dataclass -class _FakeShell: - models: list - - -def test_loading_text_shows_first_then_atomic_reveal(root): - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - - relayout_calls: list[list] = [] - original_relayout = view._relayout - - def spy_relayout(force=False): - relayout_calls.append(list(view._cards)) - return original_relayout(force) - - view._relayout = spy_relayout - - # Right after construction (before any event-loop tick), loading must - # already be showing and no cards built yet -- _start_loading() sets - # this up synchronously in __init__. - assert view._loading is True - assert view._cards == [] - assert view._loading_overlay._label.place_info() != {} - - _pump_until(root, lambda: not view._loading) - _pump(root, _FADE_SETTLE_MS) - - assert view._loading_overlay._label.place_info() == {} - assert len(view._cards) == len(shell.models) - for _model, card in view._cards: - assert card.grid_info() != {} - - # Cards only ever get gridded once all of them are built -- there must - # be no _relayout call while some cards exist and others don't (that - # would mean a partial/progressive reveal, which is the jank we removed). - assert relayout_calls, "expected at least one _relayout call" - for snapshot in relayout_calls: - assert len(snapshot) in (0, len(shell.models)) - - final_cards = [card for _model, card in relayout_calls[-1]] - assert len(final_cards) == len(shell.models) - - -def test_configure_on_a_card_does_not_trigger_relayout(root): - # Tk's bindtags put the scroll frame's pathname in every descendant - # card's bindtags too, so a plain bind() fires on each card's own - # Configure as well as the scroll frame's -- the handler must filter - # to the scroll frame's own Configure only. - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - _pump_until(root, lambda: not view._loading) - _pump(root, _FADE_SETTLE_MS) - assert view._cards, "expected cards to have loaded" - - relayout_calls: list = [] - view._relayout = lambda force=False: relayout_calls.append(force) - - _, first_card = view._cards[0] - first_card.event_generate("") - _pump(root, 20) - assert relayout_calls == [] - - view._scroll.event_generate("") - _pump(root, 20) - assert relayout_calls == [False] - - -def test_navigating_away_mid_load_does_not_crash(root): - shell = _FakeShell(models=list_models()) - view = RegistryView(root, shell) - assert view._loading is True - # Destroy before the event loop has ticked at all, so the next-card - # callback scheduled in _start_loading() is still pending in Tcl's - # queue when the widget goes away. - view.destroy() - # That pending after() callback must not raise once its target widget - # is gone. - _pump(root, 300) - - -def test_zero_models_does_not_crash(root): - shell = _FakeShell(models=[]) - view = RegistryView(root, shell) - _pump_until(root, lambda: not view._loading) - assert view._cards == [] diff --git a/tests/test_resources_view.py b/tests/test_resources_view.py deleted file mode 100644 index 17b2c45..0000000 --- a/tests/test_resources_view.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Smoke tests for ResourcesView's scan/download/cancel/error flows. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import threading -from dataclasses import dataclass -from pathlib import Path - -import pytest - -ctk = pytest.importorskip("customtkinter") - -import norefund.gui.resources_view as resources_view_module # noqa: E402 -from norefund.core.resources import ( # noqa: E402 - DownloadCancelled, - ManagedDir, - ResourceDownloadError, - ResourceReport, - TokenizerResource, -) -from norefund.gui import formatting # noqa: E402 -from norefund.gui.resources_view import ResourcesView, _TokenizerRow # noqa: E402 - -from .conftest import _pump, _pump_until # noqa: E402 - - -@dataclass -class _FakeShell: - models: list - - -def _make_resource( - key="tiktoken:o200k_base", *, cached=False, notes=None -) -> TokenizerResource: - return TokenizerResource( - key=key, - backend="tiktoken", - name="o200k_base", - model_ids=("openai:gpt-4o",), - is_cached=cached, - cache_path=Path("/fake/cache/o200k_base") if cached else None, - size_bytes=1024 if cached else None, - source_url="https://example.invalid/o200k_base.tiktoken", - notes=notes, - ) - - -def _make_report(*resources: TokenizerResource) -> ResourceReport: - total = sum(r.size_bytes or 0 for r in resources) - return ResourceReport( - tokenizers=list(resources), - dirs=[ - ManagedDir( - label="Config", - path=Path("/fake/config"), - exists=True, - size_bytes=42, - file_count=1, - ) - ], - total_tokenizer_bytes=total, - ) - - -def _action_buttons(row: _TokenizerRow) -> list[ctk.CTkButton]: - return [ - w for w in row._action_area.winfo_children() if isinstance(w, ctk.CTkButton) - ] - - -def _patch_scan(monkeypatch, result) -> None: - """Point ResourcesView's scan at a fake build_resource_report. - - `result` may be a ResourceReport (returned as-is) or any callable - taking `models` (used directly), so slow/blocking fakes work too. - """ - fn = result if callable(result) else (lambda models: result) - monkeypatch.setattr(resources_view_module, "build_resource_report", fn) - - -def test_scan_renders_rows_and_stat_pills(root, monkeypatch): - report = _make_report( - _make_resource(cached=True), - _make_resource(key="tiktoken:cl100k_base"), - ) - _patch_scan(monkeypatch, report) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - assert set(view._rows) == {r.key for r in report.tokenizers} - assert view._downloaded_pill._value_label.cget("text") == "1 of 2" - assert view._size_pill._value_label.cget("text") == formatting.fmt_bytes( - report.total_tokenizer_bytes - ) - - -def test_download_progress_flips_row_to_cached_and_updates_pills(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - progress_calls: list[tuple[int, int | None]] = [] - - def fake_download(res, *, on_progress=None, cancel_event=None): - if on_progress is not None: - on_progress(512, 1024) - progress_calls.append((512, 1024)) - return _make_resource(key=res.key, cached=True) - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - assert progress_calls - row = view._rows[resource.key] - buttons = _action_buttons(row) - assert any("Open folder" in b.cget("text") for b in buttons) - assert view._downloaded_pill._value_label.cget("text") == "1 of 1" - - -def test_cancel_resets_row_to_download_state(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - def fake_download(res, *, on_progress=None, cancel_event=None): - raise DownloadCancelled() - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - row = view._rows[resource.key] - labels = [b.cget("text") for b in _action_buttons(row)] - assert any("Download" in text for text in labels) - assert not any("Cancel" in text for text in labels) - - -def test_download_error_renders_inline(root, monkeypatch): - resource = _make_resource(cached=False) - report = _make_report(resource) - _patch_scan(monkeypatch, report) - - def fake_download(res, *, on_progress=None, cancel_event=None): - raise ResourceDownloadError("network unreachable") - - monkeypatch.setattr(resources_view_module, "download_tokenizer", fake_download) - - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - view.start_download(view._report.tokenizers[0]) - _pump_until(root, lambda: not view.is_busy()) - - row = view._rows[resource.key] - assert row._notes_label.cget("text") == "network unreachable" - assert row._notes_label.grid_info() != {} - - -def test_gated_repo_note_shows_open_page_link(root, monkeypatch): - _patch_scan(monkeypatch, _make_report()) - view = ResourcesView(root, _FakeShell(models=[])) - _pump_until(root, lambda: not view._loading) - - gated = TokenizerResource( - key="hf:meta-llama/Meta-Llama-3-8B", - backend="hf", - name="Meta-Llama-3-8B", - model_ids=("meta:llama-3-8b",), - is_cached=False, - cache_path=None, - size_bytes=None, - source_url="https://huggingface.co/meta-llama/Meta-Llama-3-8B", - notes="Gated repo — requires HF account/token", - ) - row = _TokenizerRow( - view._scroll, - gated, - is_downloading=view.is_downloading, - is_busy=view.is_busy, - start_download=view.start_download, - cancel_download=view.cancel_download, - ) - assert row._notes_label.cget("text").endswith("open page") - assert row._notes_label.cget("cursor") == "hand2" - - -def test_destroy_mid_scan_does_not_crash(root, monkeypatch): - proceed = threading.Event() - - def slow_report(models): - proceed.wait(2) - return _make_report() - - _patch_scan(monkeypatch, slow_report) - view = ResourcesView(root, _FakeShell(models=[])) - assert view._loading is True - view.destroy() - proceed.set() - _pump(root, 300) diff --git a/tests/test_tabbar.py b/tests/test_tabbar.py deleted file mode 100644 index 3fd4d79..0000000 --- a/tests/test_tabbar.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Tests for the shared TabBar widget (used by Parser and Compare). - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.gui.theme import COLORS # noqa: E402 -from norefund.gui.widgets import TabBar # noqa: E402 - -from .conftest import _pump # noqa: E402 - - -def test_active_tab_starts_highlighted(root): - tab_bar = TabBar( - root, [("a", "Alpha"), ("b", "Bravo")], "a", on_change=lambda _t: None - ) - tab_bar.pack() - _pump(root, 20) - - assert tab_bar._buttons["a"].cget("text_color") == COLORS["primary"] - assert tab_bar._buttons["b"].cget("text_color") == COLORS["muted_fg"] - - -def test_clicking_a_tab_restyles_and_notifies(root): - calls: list[str] = [] - tab_bar = TabBar( - root, [("a", "Alpha"), ("b", "Bravo")], "a", on_change=calls.append - ) - tab_bar.pack() - _pump(root, 20) - - tab_bar._buttons["b"].invoke() - _pump(root, 20) - - assert calls == ["b"] - assert tab_bar._buttons["b"].cget("text_color") == COLORS["primary"] - assert tab_bar._buttons["a"].cget("text_color") == COLORS["muted_fg"] diff --git a/tests/test_theme_contrast.py b/tests/test_theme_contrast.py deleted file mode 100644 index 3ad4444..0000000 --- a/tests/test_theme_contrast.py +++ /dev/null @@ -1,52 +0,0 @@ -"""WCAG AA contrast-ratio checks for color combos GUI_REVIEW.md flagged as -failing (< 4.5:1 for text): the primary-button label, and every provider -badge's text-on-tint. Uses the same blend() math the app itself uses to -build these colors, so a color-token change that breaks contrast again -fails a test instead of only being caught by eye.""" - -from __future__ import annotations - -from norefund.gui.formatting import _hex_to_rgb, blend -from norefund.gui.theme import COLORS, PROVIDER_COLORS - -_AA_TEXT_MIN = 4.5 - - -def _luminance(hex_color: str) -> float: - r, g, b = _hex_to_rgb(hex_color) - - def linear(channel: int) -> float: - c = channel / 255 - return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 - - return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b) - - -def _contrast_ratio(hex_a: str, hex_b: str) -> float: - lum_a, lum_b = _luminance(hex_a), _luminance(hex_b) - lighter, darker = max(lum_a, lum_b), min(lum_a, lum_b) - return (lighter + 0.05) / (darker + 0.05) - - -def test_primary_button_label_clears_aa_in_both_modes(): - primary = COLORS["primary"] - primary_fg = COLORS["primary_fg"] - for light_or_dark in (0, 1): - ratio = _contrast_ratio(primary[light_or_dark], primary_fg[light_or_dark]) - assert ratio >= _AA_TEXT_MIN, (light_or_dark, ratio) - - -def test_provider_badges_clear_aa_in_both_modes(): - # Mirrors ProviderBadge's own construction: a light-alpha blend for the - # background, blended toward black (light mode) / white (dark mode) by - # the same fraction for the text. - text_blend = 0.4 - bg_alpha = (0.13, 0.18) - card = COLORS["card"] - - for provider, accent in PROVIDER_COLORS.items(): - for mode in (0, 1): - bg = blend(accent, card[mode], bg_alpha[mode]) - text = blend("#000000" if mode == 0 else "#ffffff", accent, text_blend) - ratio = _contrast_ratio(text, bg) - assert ratio >= _AA_TEXT_MIN, (provider, mode, ratio) diff --git a/tests/test_widgets.py b/tests/test_widgets.py deleted file mode 100644 index 4a1cfd4..0000000 --- a/tests/test_widgets.py +++ /dev/null @@ -1,364 +0,0 @@ -"""Tests for the generic DropdownButton/DropdownPopover widget. - -Requires a real or virtual (e.g. Xvfb) X11 display. Skips cleanly when -none is available, matching CLAUDE.md's "GUI issues verified manually" -policy while still giving CI something to run when a display exists. -""" - -from __future__ import annotations - -import pytest - -ctk = pytest.importorskip("customtkinter") - -from norefund.gui import theme # noqa: E402 -from norefund.gui.widgets import ( # noqa: E402 - DropdownButton, - DropdownItem, - _popover_geometry, -) - -from .conftest import _pump # noqa: E402 - -_ITEMS = [ - DropdownItem(value="a", label="Alpha"), - DropdownItem(value="b", label="Bravo"), - DropdownItem(value="c", label="Charlie"), -] - - -def test_shows_initial_selected_label(root): - button = DropdownButton(root, _ITEMS, "b", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - assert button._text_label.cget("text") == "Bravo" - - -def test_select_updates_display_without_firing_callback(root): - calls: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=calls.append) - button.pack() - - button.select("c") - - assert button.selected_value() == "c" - assert button._text_label.cget("text") == "Charlie" - assert calls == [] - - -def test_toggle_opens_popover_matching_trigger_width(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.configure(width=300) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - - assert button._popover is not None - assert button._popover.winfo_exists() - popover_width = int(button._popover.geometry().split("x")[0]) - assert popover_width == max(button.winfo_width(), 220) - - button._popover.destroy() - - -def test_picking_a_row_fires_callback_updates_selection_and_closes(root): - calls: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=calls.append) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - popover._pick("c") - _pump(root, 20) - - assert calls == ["c"] - assert button.selected_value() == "c" - assert not popover.winfo_exists() - assert button._popover is None - - -def test_toggle_twice_closes_without_picking(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - button._toggle() - _pump(root, 20) - - assert not popover.winfo_exists() - assert button._popover is None - - -def test_hover_and_selected_row_colors(root): - # Rows are plain tk.Frame (not CTkFrame) for construction speed, so - # bindings land directly on the row -- no internal-canvas indirection. - is_dark = ctk.get_appearance_mode() == "Dark" - resting = theme.resolve("popover", is_dark) - selected_resting = theme.resolve("sidebar_accent", is_dark) - hover = theme.resolve("popover_hover", is_dark) - - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - popover = button._popover - - selected_row = popover.rows["a"] - other_row = popover.rows["b"] - assert str(selected_row.cget("bg")) == selected_resting - assert str(other_row.cget("bg")) == resting - - other_row.event_generate("") - _pump(root, 20) - assert str(other_row.cget("bg")) == hover - - other_row.event_generate("") - _pump(root, 20) - assert str(other_row.cget("bg")) == resting - - # Hovering the selected row itself must not lose its selected tint. - selected_row.event_generate("") - _pump(root, 20) - assert str(selected_row.cget("bg")) == hover - selected_row.event_generate("") - _pump(root, 20) - assert str(selected_row.cget("bg")) == selected_resting - - popover.destroy() - - -def test_popover_follows_window_on_resize(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.geometry("500x400+0+0") - _pump(root, 20) - - button._toggle() - _pump(root, 20) - popover = button._popover - before = popover.geometry() - - root.geometry("900x700+50+50") - _pump(root, 20) - after = popover.geometry() - - assert after != before - popover.destroy() - - -def test_popover_matches_trigger_width_at_hidpi_scaling(root): - # Regression: CTkToplevel.geometry() re-multiplies width/height (but not - # x/y) by the window's scaling factor -- at scaling 1.0 that's a no-op, - # which is why the plain width test above didn't catch this. Force a - # HiDPI-like scaling factor and confirm the popover still renders at the - # trigger's real device-pixel width, not scaled again on top of it. - ctk.ScalingTracker.set_widget_scaling(1.5) - ctk.ScalingTracker.set_window_scaling(1.5) - try: - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.configure(width=300) - button.pack() - _pump(root, 20) - - button._toggle() - _pump(root, 30) - - expected = max(button.winfo_width(), 220) - assert abs(button._popover.winfo_width() - expected) <= 1 - button._popover.destroy() - finally: - ctk.ScalingTracker.set_widget_scaling(1.0) - ctk.ScalingTracker.set_window_scaling(1.0) - _pump(root, 20) - - -def test_popover_geometry_flips_upward_near_bottom_of_screen(root, monkeypatch): - # Drives _popover_geometry directly rather than through a real toggle: - # under a WM-less Xvfb (no window manager to honor absolute placement - # requests for a plain, non-override-redirect toplevel), `root`/`button` - # winfo_rooty() stays pinned at 0 regardless of any geometry() call, so - # a real end-to-end version of this test can't reliably force "anchor - # near the bottom of the screen" -- monkeypatching the winfo methods - # this function actually reads exercises the same decision directly. - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - monkeypatch.setattr(button, "winfo_rooty", lambda: 900) - monkeypatch.setattr(button, "winfo_screenheight", lambda: 1000) - - geometry = _popover_geometry(button, root, row_count=3, row_height=42) - - y = int(geometry.rsplit("+", 1)[-1]) - assert y < 900 # opened above the anchor, not below it off-screen - - -def test_unmap_closes_open_popovers(root): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - assert button._popover is not None - - root.event_generate("") - _pump(root, 20) - - assert button._popover is None - - -def test_focus_out_closes_open_popovers_when_app_loses_focus(root, monkeypatch): - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - _pump(root, 20) - button._toggle() - _pump(root, 20) - assert button._popover is not None - - monkeypatch.setattr(root, "focus_get", lambda: None) - root.event_generate("") - _pump(root, 20) - - assert button._popover is None - - -def test_close_all_closes_every_open_popover(root): - b1 = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - b2 = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - b1.pack() - b2.pack() - _pump(root, 20) - - b1._toggle() - b2._toggle() - _pump(root, 20) - assert b1._popover is not None - assert b2._popover is not None - - DropdownButton.close_all() - _pump(root, 20) - - assert b1._popover is None - assert b2._popover is None - - -# Keyboard-navigation tests below need a *visible* (not withdrawn) root: -# real KeyPress dispatch follows actual X input focus, and under a -# window-manager-less Xvfb (this test environment), a withdrawn toplevel -# can never hold real focus, so focus_set() silently no-ops and no -# KeyPress ever arrives. A visible root's own focus_force() (bypassing -# WM cooperation entirely, since there's no WM here to cooperate with) -# does work, which is what these use instead of the shared `root` fixture. -@pytest.fixture -def visible_root(): - try: - r = ctk.CTk() - except Exception as exc: # no display available - pytest.skip(f"no Tk display available: {exc}") - yield r - r.destroy() - - -def test_return_and_space_open_the_dropdown(visible_root): - root = visible_root - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - - assert button._popover is not None - button._popover.destroy() - root.update() - - button._canvas.event_generate("") - root.update() - assert button._popover is not None - - -def test_focus_in_and_out_toggle_the_border_color(visible_root): - root = visible_root - button = DropdownButton(root, _ITEMS, "a", on_select=lambda _v: None) - button.pack() - root.update() - rest_color = button.cget("border_color") - - button._canvas.focus_force() - root.update() - assert button.cget("border_color") == theme.COLORS["primary"] - - button._canvas.event_generate("") - root.update() - assert button.cget("border_color") == rest_color - - -def test_arrow_keys_move_highlight_and_return_selects_and_closes(visible_root): - root = visible_root - picks: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=picks.append) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - popover = button._popover - assert popover is not None - popover.focus_force() - root.update() - - assert popover._highlighted == "a" - popover.event_generate("") - root.update() - assert popover._highlighted == "b" - popover.event_generate("") - root.update() - assert popover._highlighted == "c" - popover.event_generate("") - root.update() - assert popover._highlighted == "b" - - popover.event_generate("") - root.update() - - assert picks == ["b"] - assert button.selected_value() == "b" - assert button._popover is None - # Focus returns to the trigger so Tab/Return keep working right after. - assert root.focus_get() is button._canvas - - -def test_escape_closes_popover_without_selecting(visible_root): - root = visible_root - picks: list[str] = [] - button = DropdownButton(root, _ITEMS, "a", on_select=picks.append) - button.pack() - root.update() - - button._canvas.focus_force() - root.update() - button._canvas.event_generate("") - root.update() - popover = button._popover - popover.focus_force() - root.update() - - popover.event_generate("") - root.update() - popover.event_generate("") - root.update() - - assert picks == [] - assert button.selected_value() == "a" - assert button._popover is None From 462294938508fe5e208d267a7ed9b70c2eefa7fa Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Sat, 22 Aug 2026 11:06:38 +0530 Subject: [PATCH 12/17] test: fix desktop dto path test to be OS-agnostic to_jsonable() correctly returns a Path's native string form, but the test hardcoded a posix-style literal, which only matched on Linux/macOS. Removing the legacy Tk suite let the Windows CI job actually finish pytest instead of hanging, which is what surfaced this. --- tests/test_desktop_dto.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/test_desktop_dto.py b/tests/test_desktop_dto.py index b1ea988..913fd59 100644 --- a/tests/test_desktop_dto.py +++ b/tests/test_desktop_dto.py @@ -41,9 +41,14 @@ def test_nested_dataclasses_are_converted_recursively(): def test_tuples_become_lists_and_paths_become_strings(): - out = to_jsonable({"warnings": ("a", "b"), "p": Path("/tmp/x.pdf")}) + original = Path("/tmp/x.pdf") + out = to_jsonable({"warnings": ("a", "b"), "p": original}) assert out["warnings"] == ["a", "b"] - assert out["p"] == "/tmp/x.pdf" + assert isinstance(out["p"], str) + # Native separators are correct here (Windows users should see + # backslash paths, not a posix path forced on them) -- so compare via + # round-trip rather than hardcoding a posix literal. + assert Path(out["p"]) == original def test_datetimes_become_iso_strings(): From f9a3d03586669f0b5e3c20a60fb4687253c24669 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Sun, 23 Aug 2026 07:36:57 +0530 Subject: [PATCH 13/17] fix: wait for pywebview bridge readiness in every bridge call useSettings called bridge.getSettings() directly, without waiting for window.pywebview.api to be injected -- a race it consistently lost on some machines, throwing "Python bridge is not ready" immediately at mount. That error was caught into local state App.tsx never reads, so the app hung forever on the loading spinner with no visible error. Move the bridgeReady() wait into call() itself so every bridge method waits for readiness by construction, instead of relying on each call site to remember to do it (App.tsx's own effect did remember; useSettings did not). Add bridge.test.ts covering the race and the timeout path. --- frontend/src/App.tsx | 5 +-- frontend/src/lib/bridge.test.ts | 71 +++++++++++++++++++++++++++++++++ frontend/src/lib/bridge.ts | 10 ++++- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/bridge.test.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ea7b3cd..bb287c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,7 @@ import { useSettings } from "@/hooks/useSettings"; import { useShortcuts } from "@/hooks/useShortcuts"; import { useJob } from "@/hooks/useJob"; import { useTheme, type ThemeMode } from "@/hooks/useTheme"; -import { bridge, bridgeReady, BridgeError } from "@/lib/bridge"; +import { bridge, BridgeError } from "@/lib/bridge"; import type { ExchangeRates, ModelInfo, ResourceReport } from "@/lib/types"; import Calculator from "@/views/Calculator"; @@ -49,8 +49,7 @@ export default function App() { useEffect(() => { let cancelled = false; - bridgeReady() - .then(() => Promise.all([bridge.getModels(), bridge.getExchangeRates()])) + Promise.all([bridge.getModels(), bridge.getExchangeRates()]) .then(([m, rates]) => { if (cancelled) return; setModels(m); diff --git a/frontend/src/lib/bridge.test.ts b/frontend/src/lib/bridge.test.ts new file mode 100644 index 0000000..d028aac --- /dev/null +++ b/frontend/src/lib/bridge.test.ts @@ -0,0 +1,71 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { bridge, bridgeReady, BridgeError } from "./bridge"; + +// pywebview injects window.pywebview.api asynchronously, on its own timing +// relative to the page's own scripts. A component that calls a bridge +// method the instant it mounts can win or lose that race depending on the +// machine -- these tests pin down that every call waits it out instead of +// failing the moment the api object isn't there yet (the bug behind PR #43: +// useSettings called bridge.getSettings() directly, lost the race on some +// machines, and the resulting error was silently swallowed into a state +// nothing rendered -- an infinite loading spinner with no error on screen). + +function setPywebview(api: Record Promise> | undefined) { + (globalThis as { window: unknown }).window = api ? { pywebview: { api } } : {}; +} + +beforeEach(() => { + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "Date"] }); + setPywebview(undefined); +}); + +afterEach(() => { + vi.useRealTimers(); + delete (globalThis as { window?: unknown }).window; +}); + +describe("bridgeReady", () => { + it("resolves once window.pywebview.api appears, even if it wasn't there yet", async () => { + const ready = bridgeReady(); + await vi.advanceTimersByTimeAsync(100); + setPywebview({}); + await vi.advanceTimersByTimeAsync(100); + await expect(ready).resolves.toBeUndefined(); + }); + + it("rejects if the bridge never becomes ready within the timeout", async () => { + const ready = bridgeReady(); + ready.catch(() => {}); // fake-timer advancement below is what settles it; avoid an unhandled-rejection warning in between + // A touch past the 10s deadline -- the poll loop checks Date.now() > + // deadline, so landing exactly on it is one tick short of tripping. + await vi.advanceTimersByTimeAsync(10_100); + await expect(ready).rejects.toThrow(/did not become ready/); + }); +}); + +describe("bridge calls", () => { + it("wait for a not-yet-ready bridge instead of rejecting immediately", async () => { + const get_settings = vi.fn().mockResolvedValue({ ok: true, data: { theme: "dark" } }); + const promise = bridge.getSettings(); + + // Still not ready -- must not have rejected synchronously with + // "Python bridge is not ready" the way a naive `if (!api) throw` would. + await vi.advanceTimersByTimeAsync(100); + + setPywebview({ get_settings }); + await vi.advanceTimersByTimeAsync(100); + + await expect(promise).resolves.toEqual({ theme: "dark" }); + }); + + it("still rejects for a method the backend never exposed", async () => { + setPywebview({}); + await expect(bridge.getSettings()).rejects.toThrow(/Unknown bridge method/); + }); + + it("surfaces a backend error via BridgeError", async () => { + setPywebview({ get_settings: vi.fn().mockResolvedValue({ ok: false, error: "boom" }) }); + await expect(bridge.getSettings()).rejects.toThrow(BridgeError); + await expect(bridge.getSettings()).rejects.toThrow("boom"); + }); +}); diff --git a/frontend/src/lib/bridge.ts b/frontend/src/lib/bridge.ts index 625820b..1ba04f8 100644 --- a/frontend/src/lib/bridge.ts +++ b/frontend/src/lib/bridge.ts @@ -25,8 +25,14 @@ export class BridgeError extends Error {} /** The one place the bridge's return type is unchecked — narrowed * immediately below via the `ok`/`error` envelope check. */ async function call(method: string, ...args: unknown[]): Promise { - const api = window.pywebview?.api; - if (!api) throw new BridgeError("Python bridge is not ready"); + // pywebview injects window.pywebview.api asynchronously, on its own + // schedule relative to React mounting -- a call made the instant a + // component mounts can easily lose that race. Waiting here, once, means + // no call site has to remember to do it (a prior version required each + // caller to await bridgeReady() itself; the one that didn't shipped a + // silent bridge failure disguised as an infinite loading spinner). + await bridgeReady(); + const api = window.pywebview!.api; const fn = api[method]; if (!fn) throw new BridgeError(`Unknown bridge method: ${method}`); const raw = (await fn(...args)) as Envelope; From b3bc60d1c66318d78c915889bd1d683db7ed4d4d Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Sun, 23 Aug 2026 10:32:36 +0530 Subject: [PATCH 14/17] fix: strip Mark-of-the-Web from frozen bundle so pythonnet loads on downloaded builds Files extracted from a GitHub-downloaded zip get tagged as "from the internet" (Zone.Identifier), and .NET Framework refuses to load pythonnet's DLL from a tagged file -- crashing before the app window ever appears. Strip the tag from our own bundle at startup, before pythonnet loads. Also widen missing_runtime_message()'s except clause, which only caught ImportError and let this RuntimeError through as a raw traceback instead of a readable message. --- src/norefund/desktop/app.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/norefund/desktop/app.py b/src/norefund/desktop/app.py index d7e5f43..991c9f3 100644 --- a/src/norefund/desktop/app.py +++ b/src/norefund/desktop/app.py @@ -30,6 +30,30 @@ if _typelib_dir and not (os.path.isdir(_typelib_dir) and os.listdir(_typelib_dir)): os.environ.pop("GI_TYPELIB_PATH") + +def _unblock_frozen_bundle() -> None: + """Remove Windows' "downloaded from the internet" tag from our files. + + Extracting a build downloaded from GitHub tags every file with a + Zone.Identifier stream. .NET Framework then refuses to load pythonnet's + DLL from a tagged file, crashing before the app can even start (a + locally built .exe never gets tagged, so this only bites downloaded + builds). Clearing the tag here, before pythonnet loads, avoids that. + """ + if not getattr(sys, "frozen", False): + return + bundle_dir = Path(sys.executable).parent + for file_path in bundle_dir.rglob("*"): + if file_path.is_file(): + try: + os.remove(f"{file_path}:Zone.Identifier") + except OSError: + pass # not tagged, or couldn't remove it -- either way, move on + + +if sys.platform == "win32": + _unblock_frozen_bundle() + import webview # noqa: E402 from norefund.core.paths import bundled_resource # noqa: E402 @@ -101,11 +125,19 @@ def missing_runtime_message() -> str | None: if sys.platform == "win32": try: import clr # noqa: F401 - except ImportError: + except Exception: + # Broad on purpose -- missing WebView2 raises ImportError, but + # pythonnet failing to load its .NET host raises other types + # too. Either way the app can't start; show a message instead + # of a raw traceback. return ( "NoRefund needs the Microsoft Edge WebView2 runtime.\n\n" "Download it from:\n" - " https://developer.microsoft.com/microsoft-edge/webview2/" + " https://developer.microsoft.com/microsoft-edge/webview2/\n\n" + "If that's already installed, this build's files may be " + "blocked as downloaded from the internet -- right-click the " + "extracted folder, choose Properties, and click Unblock " + "(or re-download and extract again)." ) return None From 329c0a92008a8a1b93b299284cd09899fa2d48f7 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Sun, 23 Aug 2026 16:23:57 +0530 Subject: [PATCH 15/17] ci: bump actions to Node 24 majors; docs: note Windows SmartScreen warning actions/checkout@v4, setup-python@v5, setup-node@v4, upload-artifact@v4, and download-artifact@v4 all still targeted the now-deprecated Node 20 runtime. Bumped each to its current major (checkout v7, setup-node v7, setup-python v7, upload-artifact v7, download-artifact v8) across build.yml, ci.yml, and release.yml -- checked each release's changelog for breaking changes against how this repo actually uses them; none apply. Also added a Download section to the README explaining the Windows SmartScreen warning users hit when running the unsigned .exe -- the app isn't code-signed (no budget for a cert), so this can't be removed from CI alone; documenting it is the honest option for now. --- .github/workflows/build.yml | 12 ++++++------ .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 22 +++++++++++----------- README.md | 14 ++++++++++++++ 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1dc7996..1324b1e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -19,13 +19,13 @@ jobs: runs-on: ${{ matrix.os }} timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22" cache: "npm" @@ -115,21 +115,21 @@ jobs: codesign --force --deep --sign - dist/NoRefund.app tar -czf NoRefund-macos.tar.gz -C dist NoRefund.app - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: matrix.os == 'ubuntu-latest' with: name: linux-build path: NoRefund-linux-x86_64.tar.gz if-no-files-found: error - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: matrix.os == 'windows-latest' with: name: windows-build path: dist/NoRefund if-no-files-found: error - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 if: matrix.os == 'macos-latest' with: name: macos-build diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd629a1..86e58f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,9 +12,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index df7dc5e..02dcff3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ jobs: outputs: version: ${{ steps.resolve.outputs.version }} steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - name: Resolve version id: resolve @@ -48,13 +48,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 20 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22" cache: "npm" @@ -96,7 +96,7 @@ jobs: VERSION="${{ needs.version.outputs.version }}" tar -czf "NoRefund-${VERSION}-linux-x86_64.tar.gz" -C dist NoRefund - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: linux-build path: NoRefund-*-linux-x86_64.tar.gz @@ -106,13 +106,13 @@ jobs: needs: version runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v7 with: python-version: "3.12" - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7 with: node-version: "22" cache: "npm" @@ -144,7 +144,7 @@ jobs: $version = "${{ needs.version.outputs.version }}" iscc "/DMyAppVersion=$version" packaging\windows\installer.iss - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@v7 with: name: windows-build path: packaging/windows/dist/NoRefund-Setup-*.exe @@ -157,9 +157,9 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@v8 with: path: artifacts diff --git a/README.md b/README.md index d78cc4e..0d608b6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,20 @@ used only when you explicitly download a tokenizer, from the app's Resources vie --- +## Download + +Prebuilt Windows, macOS, and Linux builds are on the +[Releases page](https://github.com/Phantom-VK/NoRefund/releases) — no Python install +required. + +**Windows SmartScreen:** NoRefund isn't code-signed (a signing certificate costs money +this free project doesn't have), so Windows shows a "Windows protected your PC" warning +the first time you run it. Click **More info → Run anyway** to continue. This warning +means the publisher isn't verified, not that the app is unsafe — the source is right +here in this repo. + +--- + ## Quick Start ```bash From ba789af4ef8b0777b5539ea6a317be6ef6454785 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 10:23:12 +0530 Subject: [PATCH 16/17] ci: add missing macOS release build and fix Gatekeeper troubleshooting docs release.yml never actually built or shipped a macOS artifact -- the release job's needs list only covered Linux and Windows, so a tagged release had nothing for Mac users to download. Add a build-macos job mirroring the Linux/Windows ones (ad-hoc sign, tar.gz, versioned filename) and wire it into the release job. Also add a macOS smoke test to build.yml for parity with the existing Linux/Windows launch checks, and update the packaging README: the right-click-to-Open workaround for an ad-hoc-signed app often doesn't surface on current macOS, which instead shows a misleading "is damaged" dialog -- document the xattr -cr fix that actually works, and note notarization is deliberately deferred (no budget for it yet) rather than an oversight. --- .github/workflows/build.yml | 16 ++++++++++ .github/workflows/release.yml | 58 ++++++++++++++++++++++++++++++++++- .gitignore | 3 +- packaging/README.md | 21 ++++++++++--- 4 files changed, 91 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1324b1e..0a04e89 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -105,6 +105,22 @@ jobs: } Stop-Process -Id $proc.Id -Force + - name: Smoke test the binary actually launches (macOS) + if: matrix.os == 'macos-latest' + run: | + # py2app names the bundle executable after the plist, not + # assumed to match "NoRefund" -- read it back rather than + # guessing, same reasoning as the dynamic lookup below. + EXE=$(plutil -extract CFBundleExecutable raw dist/NoRefund.app/Contents/Info.plist) + "dist/NoRefund.app/Contents/MacOS/$EXE" & + PID=$! + sleep 5 + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::NoRefund.app exited within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + kill "$PID" + - name: Package artifact (Linux) if: matrix.os == 'ubuntu-latest' run: tar -czf NoRefund-linux-x86_64.tar.gz -C dist NoRefund diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 02dcff3..ee35283 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,8 +150,63 @@ jobs: path: packaging/windows/dist/NoRefund-Setup-*.exe if-no-files-found: error + build-macos: + needs: version + runs-on: macos-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-python@v7 + with: + python-version: "3.12" + + - uses: actions/setup-node@v7 + with: + node-version: "22" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install project + run: pip install -e ".[dev,macos]" + + - name: Build + run: python packaging/build.py + + - name: Smoke test the binary actually launches + run: | + # py2app names the bundle executable after the plist, not + # assumed to match "NoRefund" -- read it back rather than + # guessing (see build.yml's matching macOS smoke test). + EXE=$(plutil -extract CFBundleExecutable raw dist/NoRefund.app/Contents/Info.plist) + "dist/NoRefund.app/Contents/MacOS/$EXE" & + PID=$! + sleep 5 + if ! kill -0 "$PID" 2>/dev/null; then + echo "::error::NoRefund.app exited within 5s instead of staying up — the frozen build is broken." + exit 1 + fi + kill "$PID" + + - name: Ad-hoc sign and package tarball + run: | + # No Apple Developer ID ($99/yr) -- ad-hoc signing is the free + # option. It quiets Gatekeeper's outright refusal to launch but + # not the "is damaged" first-run dialog; see packaging/README.md + # for the xattr -cr workaround end users need until this project + # can afford real notarization. + VERSION="${{ needs.version.outputs.version }}" + codesign --force --deep --sign - dist/NoRefund.app + tar -czf "NoRefund-${VERSION}-macos.tar.gz" -C dist NoRefund.app + + - uses: actions/upload-artifact@v7 + with: + name: macos-build + path: NoRefund-*-macos.tar.gz + if-no-files-found: error + release: - needs: [version, build-linux, build-windows] + needs: [version, build-linux, build-windows, build-macos] if: github.event_name == 'push' runs-on: ubuntu-latest permissions: @@ -170,5 +225,6 @@ jobs: gh release create "${{ github.ref_name }}" \ artifacts/linux-build/* \ artifacts/windows-build/* \ + artifacts/macos-build/* \ --title "NoRefund ${{ github.ref_name }}" \ --generate-notes diff --git a/.gitignore b/.gitignore index 5a9311e..8704010 100644 --- a/.gitignore +++ b/.gitignore @@ -238,4 +238,5 @@ docs/superpowers/ # The Python packaging "lib/" rule above is unanchored and would otherwise # swallow the frontend's own src/lib/ directory. !/frontend/src/lib/ -!/frontend/src/lib/** \ No newline at end of file +!/frontend/src/lib/** +/graphify-out/ diff --git a/packaging/README.md b/packaging/README.md index 8050cdb..8814f7d 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -126,11 +126,22 @@ open dist/NoRefund.app **End-user requirements:** none beyond macOS 12+ -- WebKit is part of the OS, unlike Linux's WebKitGTK. -**Known limitation:** notarisation is out of scope (it needs a paid Apple -Developer account). The app is only ad-hoc signed, so Gatekeeper will -block a plain double-click on first launch. End users need to -**right-click the app -> Open**, confirm once in the dialog that appears, -and it launches normally on every run after that. +**Known limitation:** notarisation is out of scope for now (it needs a +paid $99/yr Apple Developer account) -- deliberately deferred, not an +oversight; revisit if macOS adoption justifies the cost. The app is only +ad-hoc signed, so Gatekeeper blocks a plain double-click on first launch. +On older macOS, **right-click the app -> Open** and confirm once in the +dialog that appears; every run after that launches normally. On current +macOS this often shows **"NoRefund is damaged and can't be opened"** +instead, with no right-click override -- that message is misleading (the +download isn't actually corrupt), and the fix is to open Terminal and run: + +```bash +xattr -cr /path/to/NoRefund.app +``` + +This strips the quarantine flag the browser/Finder attached on download, +after which the app opens normally on every launch. ## What is never bundled, on any platform From 0412bb57450b970cbe7e7a32bc9d0d9789eeb9a5 Mon Sep 17 00:00:00 2001 From: Phantom-VK Date: Wed, 26 Aug 2026 10:30:26 +0530 Subject: [PATCH 17/17] fix: resolve npm through shutil.which so packaging/build.py works on Windows build_frontend() called subprocess.run(["npm", "ci"]) with no shell. Windows' CreateProcess only auto-appends .exe when resolving a bare command name, not .cmd (npm's actual Windows wrapper) -- this raised FileNotFoundError/WinError2 immediately. Never caught by build.yml's own Windows job because it always passes --skip-frontend, bypassing build_frontend() entirely; only surfaced running the full release.yml build-windows job, which builds the frontend itself. --- packaging/build.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packaging/build.py b/packaging/build.py index 63f4c57..ea38db6 100644 --- a/packaging/build.py +++ b/packaging/build.py @@ -22,8 +22,12 @@ def _run(cmd: list[str], *, cwd: Path) -> None: + # Windows' CreateProcess only auto-appends .exe, not .cmd -- a bare + # "npm" (really npm.cmd on Windows) fails with WinError 2 unless + # resolved through shutil.which(), which does the full PATHEXT search. + resolved = [shutil.which(cmd[0]) or cmd[0], *cmd[1:]] print(f"== {' '.join(cmd)} (in {cwd}) ==") - result = subprocess.run(cmd, cwd=cwd) + result = subprocess.run(resolved, cwd=cwd) if result.returncode != 0: print(f"FAILED: {' '.join(cmd)}", file=sys.stderr) raise SystemExit(result.returncode)