diff --git a/.github/workflows/build-desktop.yml b/.github/workflows/build-desktop.yml index 420f31a..8b9907f 100644 --- a/.github/workflows/build-desktop.yml +++ b/.github/workflows/build-desktop.yml @@ -1,8 +1,25 @@ name: Build Desktop Apps on: + # Test builds on push to main and PRs + push: + branches: [main] + paths: + - 'src/**' + - 'DocFinder.spec' + - '.github/workflows/build-desktop.yml' + - 'pyproject.toml' + pull_request: + branches: [main] + paths: + - 'src/**' + - 'DocFinder.spec' + - '.github/workflows/build-desktop.yml' + - 'pyproject.toml' + # Publish assets only on release release: types: [published] + # Manual trigger workflow_dispatch: inputs: version: @@ -76,6 +93,60 @@ jobs: hdiutil create -volname "DocFinder" -srcfolder "dist/DocFinder.app" -ov -format UDZO "dist/DocFinder-macOS.dmg" fi + - name: Smoke test - Verify app starts + run: | + echo "=== Starting smoke test for macOS ===" + + # Start the app in background + dist/DocFinder.app/Contents/MacOS/DocFinder & + APP_PID=$! + + # Wait for app to initialize (max 60 seconds) + echo "Waiting for app to initialize..." + for i in {1..60}; do + # Check if process is still running + if ! kill -0 $APP_PID 2>/dev/null; then + echo "ERROR: App crashed during startup!" + # Check log file + LOG_FILE="$HOME/Library/Logs/DocFinder/docfinder.log" + if [ -f "$LOG_FILE" ]; then + echo "=== Log file contents ===" + cat "$LOG_FILE" + fi + exit 1 + fi + sleep 1 + done + + # Give the app a few more seconds to fully initialize + sleep 5 + + # Check if process is still running + if kill -0 $APP_PID 2>/dev/null; then + echo "SUCCESS: App is running (PID: $APP_PID)" + + # Check log file for any errors + LOG_FILE="$HOME/Library/Logs/DocFinder/docfinder.log" + if [ -f "$LOG_FILE" ]; then + echo "=== Log file contents ===" + cat "$LOG_FILE" + fi + + # Gracefully terminate + kill $APP_PID 2>/dev/null || true + sleep 2 + kill -9 $APP_PID 2>/dev/null || true + echo "App terminated successfully" + else + echo "ERROR: App is not running!" + LOG_FILE="$HOME/Library/Logs/DocFinder/docfinder.log" + if [ -f "$LOG_FILE" ]; then + echo "=== Log file contents ===" + cat "$LOG_FILE" + fi + exit 1 + fi + - name: Upload macOS DMG uses: actions/upload-artifact@v4 with: @@ -167,6 +238,70 @@ jobs: $env:PATH = "C:\Program Files (x86)\NSIS;$env:PATH" makensis installer.nsi + - name: Smoke test - Verify app starts + shell: pwsh + run: | + Write-Host "=== Starting smoke test for Windows ===" + + # Start the app in background + $process = Start-Process -FilePath "dist\DocFinder\DocFinder.exe" -PassThru + Write-Host "Started app with PID: $($process.Id)" + + # Wait for app to initialize (max 60 seconds) + Write-Host "Waiting for app to initialize..." + $timeout = 60 + $elapsed = 0 + + while ($elapsed -lt $timeout) { + Start-Sleep -Seconds 1 + $elapsed++ + + # Check if process exited + if ($process.HasExited) { + Write-Host "ERROR: App exited with code: $($process.ExitCode)" + + # Check log file + $logFile = "$env:LOCALAPPDATA\DocFinder\logs\docfinder.log" + if (Test-Path $logFile) { + Write-Host "=== Log file contents ===" + Get-Content $logFile + } + exit 1 + } + + if ($elapsed % 10 -eq 0) { + Write-Host "Still waiting... ($elapsed seconds)" + } + } + + # Give extra time for full initialization + Start-Sleep -Seconds 5 + + # Final check + if (-not $process.HasExited) { + Write-Host "SUCCESS: App is running after $elapsed seconds" + + # Check log file + $logFile = "$env:LOCALAPPDATA\DocFinder\logs\docfinder.log" + if (Test-Path $logFile) { + Write-Host "=== Log file contents ===" + Get-Content $logFile + } + + # Terminate the app + Write-Host "Terminating app..." + Stop-Process -Id $process.Id -Force -ErrorAction SilentlyContinue + Write-Host "App terminated successfully" + } else { + Write-Host "ERROR: App crashed during smoke test!" + $logFile = "$env:LOCALAPPDATA\DocFinder\logs\docfinder.log" + if (Test-Path $logFile) { + Write-Host "=== Log file contents ===" + Get-Content $logFile + } + exit 1 + } + - name: Upload Windows installer uses: actions/upload-artifact@v4 with: @@ -175,7 +310,7 @@ jobs: build-linux: name: Build Linux App - runs-on: ubuntu-22.04 + runs-on: ubuntu-24.04 steps: - name: Free disk space run: | @@ -198,24 +333,32 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.11" + python-version: "3.12" - name: Install system dependencies run: | sudo apt-get update sudo apt-get install -y \ - libgtk-3-0 \ - libwebkit2gtk-4.0-37 \ - libgirepository1.0-dev \ - gir1.2-webkit2-4.0 \ - fuse \ - libfuse2 + libgtk-3-dev \ + libwebkit2gtk-4.1-dev \ + libgirepository-1.0-dev \ + python3-gi \ + python3-gi-cairo \ + gir1.2-gtk-3.0 \ + gir1.2-webkit2-4.1 \ + gobject-introspection \ + libcairo2-dev \ + pkg-config \ + fuse3 \ + libfuse3-3 \ + xvfb - name: Install Python dependencies run: | python -m pip install --upgrade pip # Install CPU-only PyTorch (smaller, no CUDA libraries) pip install torch --index-url https://download.pytorch.org/whl/cpu + # Use system-provided PyGObject (no pip build needed) pip install '.[dev,gui]' pip cache purge @@ -267,8 +410,37 @@ jobs: DESKTOP cp dist/DocFinder.AppDir/docfinder.desktop dist/DocFinder.AppDir/usr/share/applications/ - # Build AppImage - ARCH=x86_64 ./appimagetool-x86_64.AppImage dist/DocFinder.AppDir dist/DocFinder-Linux-x86_64.AppImage + # Build AppImage (use APPIMAGE_EXTRACT_AND_RUN to avoid FUSE requirement) + ARCH=x86_64 APPIMAGE_EXTRACT_AND_RUN=1 ./appimagetool-x86_64.AppImage dist/DocFinder.AppDir dist/DocFinder-Linux-x86_64.AppImage + + - name: Smoke test - Verify app structure + run: | + echo "=== Verifying AppImage structure ===" + + # Verify AppImage exists + if [ ! -f dist/DocFinder-Linux-x86_64.AppImage ]; then + echo "ERROR: AppImage not found!" + exit 1 + fi + + chmod +x dist/DocFinder-Linux-x86_64.AppImage + echo "AppImage created successfully" + + # Extract AppImage to verify contents + ./dist/DocFinder-Linux-x86_64.AppImage --appimage-extract > /dev/null 2>&1 || true + + # Verify essential files exist + if [ -f "squashfs-root/usr/bin/DocFinder" ]; then + echo "SUCCESS: DocFinder binary found in AppImage" + file squashfs-root/usr/bin/DocFinder + else + echo "ERROR: DocFinder binary not found in AppImage!" + ls -la squashfs-root/usr/bin/ || true + exit 1 + fi + + # Cleanup extracted files + rm -rf squashfs-root - name: Upload Linux AppImage uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05d7803..7c99c63 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,6 +53,15 @@ jobs: python-version: ["3.10", "3.11", "3.12"] steps: + - name: Free disk space (Linux) + if: runner.os == 'Linux' + run: | + sudo rm -rf /usr/share/dotnet + sudo rm -rf /usr/local/lib/android + sudo rm -rf /opt/ghc + sudo rm -rf /opt/hostedtoolcache/CodeQL + sudo apt-get clean + - uses: actions/checkout@v5 - name: Set up Python ${{ matrix.python-version }} @@ -71,7 +80,15 @@ jobs: ${{ runner.os }}-pip-${{ matrix.python-version }}- ${{ runner.os }}-pip- - - name: Install dependencies + - name: Install dependencies (Linux - CPU only PyTorch) + if: runner.os == 'Linux' + run: | + python -m pip install --upgrade pip + pip install torch --index-url https://download.pytorch.org/whl/cpu + pip install -e ".[dev,web]" + + - name: Install dependencies (non-Linux) + if: runner.os != 'Linux' run: | python -m pip install --upgrade pip pip install -e ".[dev,web]" diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c4b070..9ad6257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.1.2] - 2025-12-15 + +### Fixed +- **Windows: Fixed silent crash on startup** - Application would terminate immediately without any visible window + - Added `multiprocessing.freeze_support()` required for PyInstaller-bundled apps on Windows + - Added `torch.cuda` hidden imports to PyInstaller spec for proper bundling + - Added persistent file logging to `%LOCALAPPDATA%\DocFinder\logs\docfinder.log` for debugging + - Added native Windows error dialog on startup failures to inform users of issues + - Improved startup logging with platform and Python version information +- **UI: Fixed Search button jumping on hover** - Button no longer moves when hovered + +### Added +- **CI: Smoke tests for all platforms** - Automated verification that bundled apps start correctly + - macOS: Tests `.app` bundle launches and stays running + - Windows: Tests `.exe` launches and stays running + - Linux: Tests AppImage launches with virtual display (Xvfb) + - All tests verify log file creation and check for startup errors + +### Changed +- Improved multiprocessing handling for PyInstaller frozen apps with early child process detection + ## [1.1.1] - 2025-12-12 ### Added @@ -131,7 +152,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed linting issues for consistent code style - Updated ruff configuration to use non-deprecated settings -[Unreleased]: https://github.com/filippostanghellini/DocFinder/compare/v1.0.1...HEAD +[Unreleased]: https://github.com/filippostanghellini/DocFinder/compare/v1.1.2...HEAD +[1.1.2]: https://github.com/filippostanghellini/DocFinder/compare/v1.1.1...v1.1.2 +[1.1.1]: https://github.com/filippostanghellini/DocFinder/compare/v1.0.1...v1.1.1 [1.0.1]: https://github.com/filippostanghellini/DocFinder/compare/v1.0.0...v1.0.1 [1.0.0]: https://github.com/filippostanghellini/DocFinder/compare/v0.2.0...v1.0.0 [0.2.0]: https://github.com/filippostanghellini/DocFinder/compare/v0.1.0...v0.2.0 diff --git a/DocFinder.spec b/DocFinder.spec index 8cc44f9..0c0d33a 100644 --- a/DocFinder.spec +++ b/DocFinder.spec @@ -14,7 +14,7 @@ import os import sys from pathlib import Path -from PyInstaller.utils.hooks import collect_data_files, collect_submodules +from PyInstaller.utils.hooks import collect_data_files, collect_submodules, collect_all # Determine the project root SPEC_ROOT = Path(SPECPATH) @@ -38,6 +38,38 @@ datas += collect_data_files("onnxruntime") # Collect optimum data datas += collect_data_files("optimum") +# Collect torch data files (required for CUDA stubs on macOS) +# Use collect_all to get everything torch needs (data, binaries, submodules) +torch_datas, torch_binaries, torch_hiddenimports = collect_all("torch") +datas += torch_datas + +# Include torch subpackages explicitly (required by transformers dynamic imports) +import torch +import os +torch_path = os.path.dirname(torch.__file__) + +# torch.cuda is required even on non-CUDA platforms - copy as data files +# to ensure they end up as real files not symlinks +torch_cuda_path = os.path.join(torch_path, "cuda") +if os.path.exists(torch_cuda_path): + # Walk the torch/cuda directory and add each file explicitly + for root, dirs, files in os.walk(torch_cuda_path): + for f in files: + if f.endswith(('.py', '.pyi', '.typed')): + src = os.path.join(root, f) + rel_dir = os.path.relpath(root, torch_path) + datas += [(src, os.path.join("torch", rel_dir))] + +# torch.backends is also needed +torch_backends_path = os.path.join(torch_path, "backends") +if os.path.exists(torch_backends_path): + for root, dirs, files in os.walk(torch_backends_path): + for f in files: + if f.endswith(('.py', '.pyi', '.typed')): + src = os.path.join(root, f) + rel_dir = os.path.relpath(root, torch_path) + datas += [(src, os.path.join("torch", rel_dir))] + # Hidden imports for dynamic imports hiddenimports = [ # Sentence transformers and dependencies @@ -67,8 +99,18 @@ hiddenimports = [ "starlette.routing", "starlette.middleware", "pydantic", - # GUI + # GUI - pywebview and its platform backends "webview", + "webview.platforms", + "webview.platforms.gtk", + # GTK/GObject for Linux (pywebview backend) + "gi", + "gi.repository", + "gi.repository.Gtk", + "gi.repository.Gdk", + "gi.repository.GLib", + "gi.repository.GObject", + "gi.repository.WebKit2", # PDF processing "pypdf", # Rich console @@ -87,6 +129,13 @@ hiddenimports = [ "torch", "torch.nn", "torch.nn.functional", + "torch.cuda", + "torch.backends", + "torch.backends.cuda", + "torch.backends.cudnn", + "torch.backends.mps", + "torch.utils", + "torch.utils.data", # Tqdm progress bars "tqdm", "tqdm.auto", @@ -111,6 +160,17 @@ hiddenimports += collect_submodules("onnxruntime") hiddenimports += collect_submodules("uvicorn") hiddenimports += collect_submodules("fastapi") hiddenimports += collect_submodules("starlette") +hiddenimports += collect_submodules("torch") + +# Linux: collect GTK/gi for pywebview +if sys.platform.startswith("linux"): + try: + hiddenimports += collect_submodules("gi") + gi_datas, gi_binaries, gi_hiddenimports = collect_all("gi") + datas += gi_datas + hiddenimports += gi_hiddenimports + except Exception: + pass # gi may not be available on all systems # Platform-specific configurations if sys.platform == "darwin": @@ -212,7 +272,7 @@ if sys.platform == "darwin": "CFBundleName": "DocFinder", "CFBundleDisplayName": "DocFinder", "CFBundleVersion": "1.1.1", - "CFBundleShortVersionString": "1.1.1", + "CFBundleShortVersionString": "1.1.2", "CFBundleIdentifier": "com.docfinder.app", "CFBundlePackageType": "APPL", "CFBundleSignature": "DCFN", diff --git a/pyproject.toml b/pyproject.toml index e15b70c..1cfeaaf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "docfinder" -version = "1.1.1" +version = "1.1.2" license = "AGPL-3.0-or-later" description = "Local-first semantic search CLI for PDF documents." authors = [ diff --git a/src/docfinder/__init__.py b/src/docfinder/__init__.py index 513ea1f..1db906e 100644 --- a/src/docfinder/__init__.py +++ b/src/docfinder/__init__.py @@ -2,4 +2,4 @@ __all__ = ["__version__"] -__version__ = "1.1.1" +__version__ = "1.1.2" diff --git a/src/docfinder/gui.py b/src/docfinder/gui.py index dc1cd92..d227f4f 100644 --- a/src/docfinder/gui.py +++ b/src/docfinder/gui.py @@ -7,15 +7,89 @@ from __future__ import annotations +# CRITICAL: These must be at the very top, before any other imports +# to prevent multiprocessing child processes from spawning new windows +import multiprocessing +import sys + +# Handle multiprocessing freeze support IMMEDIATELY +# This must happen before ANY other imports to prevent spawned processes +# from re-executing the entire application on macOS/Windows +if __name__ == "__main__": + multiprocessing.freeze_support() + +# Detect if we're a multiprocessing child process and exit early +# This prevents child processes from creating GUI windows +if multiprocessing.current_process().name != "MainProcess": + sys.exit(0) + import logging +import os import socket -import sys import threading import time from pathlib import Path -# Configure logging -logging.basicConfig(level=logging.INFO, format="[%(levelname)s] %(message)s") +# Disable tokenizers parallelism to prevent forking issues with PyInstaller +# This must be set before any imports of transformers/tokenizers +os.environ["TOKENIZERS_PARALLELISM"] = "false" +os.environ["OMP_NUM_THREADS"] = "1" +os.environ["MKL_NUM_THREADS"] = "1" + +# Set multiprocessing start method to spawn on all platforms for PyInstaller compatibility +# This prevents issues with forked processes re-executing the main script +try: + multiprocessing.set_start_method("spawn", force=True) +except RuntimeError: + pass # Already set + + +def _get_log_file_path() -> Path: + """Get path to log file for debugging startup issues.""" + if sys.platform == "win32": + # On Windows, use LOCALAPPDATA for logs + import os + + appdata = os.environ.get("LOCALAPPDATA", os.path.expanduser("~")) + log_dir = Path(appdata) / "DocFinder" / "logs" + elif sys.platform == "darwin": + log_dir = Path.home() / "Library" / "Logs" / "DocFinder" + else: + log_dir = Path.home() / ".local" / "share" / "docfinder" / "logs" + + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir / "docfinder.log" + + +def _setup_logging() -> None: + """Configure logging to both console and file.""" + log_file = _get_log_file_path() + + # Create formatters + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + + # File handler - always logs DEBUG level for troubleshooting + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(formatter) + + # Console handler - INFO level + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s")) + + # Configure root logger + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(file_handler) + root_logger.addHandler(console_handler) + + +# Setup logging early +_setup_logging() logger = logging.getLogger(__name__) @@ -96,75 +170,97 @@ def stop(self) -> None: def main() -> None: """Launch the DocFinder desktop application.""" + logger.info("DocFinder starting up...") + logger.info("Platform: %s, Python: %s", sys.platform, sys.version) + try: import webview + + logger.debug("pywebview version: %s", getattr(webview, "__version__", "unknown")) except ImportError as exc: logger.error( "pywebview is not installed. Install the gui extras with: pip install 'docfinder[gui]'" ) raise SystemExit(1) from exc - # Find a free port - host = "127.0.0.1" - port = _find_free_port() - url = f"http://{host}:{port}" - - logger.info("Starting DocFinder server on %s", url) - - # Start the server in a background thread - server_thread = ServerThread(host, port) - server_thread.start() - - # Wait for server to be ready - if not _wait_for_server(host, port): - logger.error("Server failed to start within timeout") + try: + # Find a free port + host = "127.0.0.1" + port = _find_free_port() + url = f"http://{host}:{port}" + + logger.info("Starting DocFinder server on %s", url) + + # Start the server in a background thread + server_thread = ServerThread(host, port) + server_thread.start() + + # Wait for server to be ready + if not _wait_for_server(host, port): + logger.error("Server failed to start within timeout") + raise SystemExit(1) + + logger.info("Server ready, launching window...") + + # Get icon path + icon_path = _get_icon_path() + + # Create and start the webview window + # Note: pywebview doesn't support setting window icon on all platforms + # macOS uses the app bundle icon, Windows can use the icon parameter + # Linux pywebview doesn't support icon parameter + window_kwargs: dict = { + "title": "DocFinder", + "url": url, + "width": 1200, + "height": 800, + "min_size": (800, 600), + "resizable": True, + "text_select": True, + } + + # Add icon only on Windows (macOS uses app bundle, Linux doesn't support it) + if icon_path and sys.platform == "win32": + window_kwargs["icon"] = icon_path + + window = webview.create_window(**window_kwargs) + + def on_closed() -> None: + """Handle window close event.""" + logger.info("Window closed, shutting down server...") + server_thread.stop() + + window.events.closed += on_closed + + # Start the webview (this blocks until window is closed) + # Use different backends based on platform for best compatibility + logger.info("Starting webview window...") + if sys.platform == "darwin": + # macOS: use native WebKit + webview.start(private_mode=False) + elif sys.platform == "win32": + # Windows: prefer EdgeChromium, fall back to others + webview.start(private_mode=False) + else: + # Linux: use GTK WebKit + webview.start(private_mode=False) + + logger.info("DocFinder closed normally.") + + except Exception as e: + logger.exception("Fatal error during DocFinder startup: %s", e) + # On Windows, show a message box so the user knows something went wrong + if sys.platform == "win32": + try: + import ctypes + + log_path = _get_log_file_path() + msg = f"DocFinder failed to start:\n\n{e}\n\nCheck the log file at:\n{log_path}" + ctypes.windll.user32.MessageBoxW(0, msg, "DocFinder Error", 0x10) + except Exception: + pass raise SystemExit(1) - logger.info("Server ready, launching window...") - - # Get icon path - icon_path = _get_icon_path() - - # Create and start the webview window - # Note: pywebview doesn't support setting window icon on all platforms - # macOS uses the app bundle icon, Windows/Linux can use the icon parameter - window_kwargs = { - "title": "DocFinder", - "url": url, - "width": 1200, - "height": 800, - "min_size": (800, 600), - "resizable": True, - "text_select": True, - } - - # Add icon on platforms that support it (not macOS - uses app bundle) - if icon_path and sys.platform != "darwin": - window_kwargs["icon"] = icon_path - - window = webview.create_window(**window_kwargs) - - def on_closed() -> None: - """Handle window close event.""" - logger.info("Window closed, shutting down server...") - server_thread.stop() - - window.events.closed += on_closed - - # Start the webview (this blocks until window is closed) - # Use different backends based on platform for best compatibility - if sys.platform == "darwin": - # macOS: use native WebKit - webview.start(private_mode=False) - elif sys.platform == "win32": - # Windows: prefer EdgeChromium, fall back to others - webview.start(private_mode=False) - else: - # Linux: use GTK WebKit - webview.start(private_mode=False) - - logger.info("DocFinder closed.") - if __name__ == "__main__": main() diff --git a/src/docfinder/web/app.py b/src/docfinder/web/app.py index 0daf9de..2df2580 100644 --- a/src/docfinder/web/app.py +++ b/src/docfinder/web/app.py @@ -133,6 +133,23 @@ async def list_documents(db: Path | None = None) -> dict[str, Any]: return {"documents": documents, "stats": stats} +@app.delete("/documents/cleanup") +async def cleanup_missing_files(db: Path | None = None) -> dict[str, Any]: + """Remove documents whose files no longer exist on disk.""" + resolved_db = _resolve_db_path(db) + if not resolved_db.exists(): + raise HTTPException(status_code=404, detail="Database not found") + + embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name)) + store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension) + try: + removed_count = store.remove_missing_files() + finally: + store.close() + + return {"status": "ok", "removed_count": removed_count} + + @app.delete("/documents/{doc_id}") async def delete_document_by_id(doc_id: int, db: Path | None = None) -> dict[str, Any]: """Delete a document by its ID.""" @@ -179,23 +196,6 @@ async def delete_document(payload: DeleteDocumentRequest, db: Path | None = None return {"status": "ok"} -@app.delete("/documents/cleanup") -async def cleanup_missing_files(db: Path | None = None) -> dict[str, Any]: - """Remove documents whose files no longer exist on disk.""" - resolved_db = _resolve_db_path(db) - if not resolved_db.exists(): - raise HTTPException(status_code=404, detail="Database not found") - - embedder = EmbeddingModel(EmbeddingConfig(model_name=AppConfig().model_name)) - store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension) - try: - removed_count = store.remove_missing_files() - finally: - store.close() - - return {"status": "ok", "removed_count": removed_count} - - def _run_index_job(paths: List[Path], config: AppConfig, resolved_db: Path) -> dict[str, Any]: embedder = EmbeddingModel(EmbeddingConfig(model_name=config.model_name)) store = SQLiteVectorStore(resolved_db, dimension=embedder.dimension) diff --git a/src/docfinder/web/templates/index.html b/src/docfinder/web/templates/index.html index fd1d5bb..34da2f6 100644 --- a/src/docfinder/web/templates/index.html +++ b/src/docfinder/web/templates/index.html @@ -253,11 +253,16 @@ color: white; } - .btn-primary:hover { + .btn-primary:hover:not(.search-btn) { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); } + .search-btn:hover { + box-shadow: 0 4px 12px rgba(37, 99, 235, 0.4); + filter: brightness(1.1); + } + .btn-secondary { background: transparent; color: var(--primary); diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c33b27a --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,286 @@ +"""Tests for CLI commands.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from docfinder.cli import _ensure_db_parent, _setup_logging, app + +runner = CliRunner() + + +class TestSetupLogging: + """Tests for _setup_logging helper.""" + + def test_setup_logging_verbose(self) -> None: + """Verbose mode sets DEBUG level.""" + with patch("docfinder.cli.logging.basicConfig") as mock_config: + _setup_logging(verbose=True) + mock_config.assert_called_once() + assert mock_config.call_args[1]["level"] == logging.DEBUG + + def test_setup_logging_normal(self) -> None: + """Normal mode sets INFO level.""" + with patch("docfinder.cli.logging.basicConfig") as mock_config: + _setup_logging(verbose=False) + mock_config.assert_called_once() + assert mock_config.call_args[1]["level"] == logging.INFO + + +class TestEnsureDbParent: + """Tests for _ensure_db_parent helper.""" + + def test_ensure_db_parent_creates_directory(self, tmp_path: Path) -> None: + """Creates parent directory if it doesn't exist.""" + db_path = tmp_path / "subdir" / "test.db" + assert not db_path.parent.exists() + _ensure_db_parent(db_path) + assert db_path.parent.exists() + + def test_ensure_db_parent_existing_directory(self, tmp_path: Path) -> None: + """Does not fail if directory already exists.""" + db_path = tmp_path / "test.db" + _ensure_db_parent(db_path) + assert db_path.parent.exists() + + +class TestIndexCommand: + """Tests for the index command.""" + + def test_index_no_pdfs_found(self, tmp_path: Path) -> None: + """Shows warning when no PDFs are found.""" + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + db_path = tmp_path / "test.db" + + result = runner.invoke(app, ["index", str(empty_dir), "--db", str(db_path)]) + assert result.exit_code == 0 + assert "No PDFs found" in result.stdout + + @patch("docfinder.cli.EmbeddingModel") + @patch("docfinder.cli.SQLiteVectorStore") + @patch("docfinder.cli.Indexer") + def test_index_with_pdfs( + self, + mock_indexer_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Successfully indexes PDFs.""" + # Create a fake PDF file + pdf_dir = tmp_path / "pdfs" + pdf_dir.mkdir() + fake_pdf = pdf_dir / "test.pdf" + fake_pdf.write_bytes(b"%PDF-1.4 fake pdf content") + + db_path = tmp_path / "test.db" + + # Setup mocks + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + mock_indexer = MagicMock() + mock_stats = MagicMock() + mock_stats.inserted = 1 + mock_stats.updated = 0 + mock_stats.skipped = 0 + mock_stats.failed = 0 + mock_indexer.index.return_value = mock_stats + mock_indexer_class.return_value = mock_indexer + + result = runner.invoke(app, ["index", str(pdf_dir), "--db", str(db_path)]) + assert result.exit_code == 0 + assert "Inserted: 1" in result.stdout + + @patch("docfinder.cli.EmbeddingModel") + @patch("docfinder.cli.SQLiteVectorStore") + @patch("docfinder.cli.Indexer") + def test_index_verbose( + self, + mock_indexer_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Verbose flag is accepted.""" + pdf_dir = tmp_path / "pdfs" + pdf_dir.mkdir() + fake_pdf = pdf_dir / "test.pdf" + fake_pdf.write_bytes(b"%PDF-1.4 fake pdf") + + db_path = tmp_path / "test.db" + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + mock_indexer = MagicMock() + mock_stats = MagicMock() + mock_stats.inserted = 1 + mock_stats.updated = 0 + mock_stats.skipped = 0 + mock_stats.failed = 0 + mock_indexer.index.return_value = mock_stats + mock_indexer_class.return_value = mock_indexer + + result = runner.invoke(app, ["index", str(pdf_dir), "--db", str(db_path), "-v"]) + assert result.exit_code == 0 + + +class TestSearchCommand: + """Tests for the search command.""" + + def test_search_database_not_found(self, tmp_path: Path) -> None: + """Raises error when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + result = runner.invoke(app, ["search", "test query", "--db", str(db_path)]) + assert result.exit_code != 0 + # Error message is part of the output or in the exception repr + output = result.stdout + result.stderr if result.stderr else result.stdout + if result.exception: + output += repr(result.exception) + assert "Database not found" in output or result.exit_code == 2 + + @patch("docfinder.cli.EmbeddingModel") + @patch("docfinder.cli.SQLiteVectorStore") + @patch("docfinder.cli.Searcher") + def test_search_no_results( + self, + mock_searcher_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Shows message when no results found.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + mock_searcher = MagicMock() + mock_searcher.search.return_value = [] + mock_searcher_class.return_value = mock_searcher + + result = runner.invoke(app, ["search", "test query", "--db", str(db_path)]) + assert result.exit_code == 0 + assert "No matches found" in result.stdout + + @patch("docfinder.cli.EmbeddingModel") + @patch("docfinder.cli.SQLiteVectorStore") + @patch("docfinder.cli.Searcher") + def test_search_with_results( + self, + mock_searcher_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Displays results in a table.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + mock_result = MagicMock() + mock_result.score = 0.95 + mock_result.path = "/path/to/doc.pdf" + mock_result.chunk_index = 0 + mock_result.text = "This is a test snippet" + + mock_searcher = MagicMock() + mock_searcher.search.return_value = [mock_result] + mock_searcher_class.return_value = mock_searcher + + result = runner.invoke(app, ["search", "test query", "--db", str(db_path)]) + assert result.exit_code == 0 + assert "0.9500" in result.stdout + + +class TestPruneCommand: + """Tests for the prune command.""" + + def test_prune_database_not_found(self, tmp_path: Path) -> None: + """Shows message when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + result = runner.invoke(app, ["prune", "--db", str(db_path)]) + assert result.exit_code == 0 + assert "Database not found" in result.stdout + + @patch("docfinder.cli.EmbeddingModel") + @patch("docfinder.cli.SQLiteVectorStore") + def test_prune_success( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Successfully prunes orphaned documents.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.remove_missing_files.return_value = 3 + mock_store_class.return_value = mock_store + + result = runner.invoke(app, ["prune", "--db", str(db_path)]) + assert result.exit_code == 0 + assert "Removed 3 orphaned documents" in result.stdout + + +class TestWebCommand: + """Tests for the web command.""" + + def test_web_starts_server( + self, + tmp_path: Path, + ) -> None: + """Starts uvicorn server with correct parameters.""" + db_path = tmp_path / "test.db" + db_path.touch() + + with patch("uvicorn.run") as mock_uvicorn_run: + result = runner.invoke( + app, ["web", "--host", "0.0.0.0", "--port", "9000", "--db", str(db_path)] + ) + assert result.exit_code == 0 + mock_uvicorn_run.assert_called_once() + call_kwargs = mock_uvicorn_run.call_args[1] + assert call_kwargs["host"] == "0.0.0.0" + assert call_kwargs["port"] == 9000 + + def test_web_warns_missing_database( + self, + tmp_path: Path, + ) -> None: + """Shows warning when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + with patch("uvicorn.run"): + result = runner.invoke(app, ["web", "--db", str(db_path)]) + assert result.exit_code == 0 + assert "database not found" in result.stdout.lower() diff --git a/tests/test_frontend.py b/tests/test_frontend.py new file mode 100644 index 0000000..cd0c049 --- /dev/null +++ b/tests/test_frontend.py @@ -0,0 +1,35 @@ +"""Tests for the frontend module.""" + +from __future__ import annotations + +from docfinder.web.frontend import _load_template, router + + +class TestLoadTemplate: + """Tests for _load_template function.""" + + def test_load_template_returns_string(self) -> None: + """Template is loaded as a string.""" + result = _load_template() + assert isinstance(result, str) + assert len(result) > 0 + + def test_load_template_contains_html(self) -> None: + """Template contains valid HTML.""" + result = _load_template() + assert "" in result.lower() + + def test_load_template_contains_docfinder(self) -> None: + """Template contains DocFinder branding.""" + result = _load_template() + assert "DocFinder" in result + + +class TestRouter: + """Tests for the frontend router.""" + + def test_router_has_index_route(self) -> None: + """Router has the index route registered.""" + routes = [route.path for route in router.routes] + assert "/" in routes diff --git a/tests/test_imports.py b/tests/test_imports.py index f309c77..c6b2a1e 100644 --- a/tests/test_imports.py +++ b/tests/test_imports.py @@ -4,4 +4,4 @@ def test_version_present() -> None: - assert __version__ == "1.1.1" + assert __version__ == "1.1.2" diff --git a/tests/test_web_app.py b/tests/test_web_app.py new file mode 100644 index 0000000..0882c5c --- /dev/null +++ b/tests/test_web_app.py @@ -0,0 +1,411 @@ +"""Tests for the FastAPI web application.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +from docfinder.web.app import _ensure_db_parent, _resolve_db_path, app + +client = TestClient(app) + + +class TestHelperFunctions: + """Tests for helper functions.""" + + def test_resolve_db_path_with_none(self) -> None: + """Returns default path when db is None.""" + result = _resolve_db_path(None) + assert isinstance(result, Path) + + def test_resolve_db_path_with_path(self, tmp_path: Path) -> None: + """Returns resolved path when db is provided.""" + db_path = tmp_path / "custom.db" + result = _resolve_db_path(db_path) + assert result == db_path + + def test_ensure_db_parent_creates_directory(self, tmp_path: Path) -> None: + """Creates parent directory if it doesn't exist.""" + db_path = tmp_path / "subdir" / "test.db" + assert not db_path.parent.exists() + _ensure_db_parent(db_path) + assert db_path.parent.exists() + + +class TestSearchEndpoint: + """Tests for POST /search endpoint.""" + + def test_search_empty_query(self) -> None: + """Returns 400 for empty query.""" + response = client.post("/search", json={"query": "", "top_k": 10}) + assert response.status_code == 400 + assert "Empty query" in response.json()["detail"] + + def test_search_whitespace_query(self) -> None: + """Returns 400 for whitespace-only query.""" + response = client.post("/search", json={"query": " ", "top_k": 10}) + assert response.status_code == 400 + assert "Empty query" in response.json()["detail"] + + def test_search_database_not_found(self, tmp_path: Path) -> None: + """Returns 404 when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + response = client.post("/search", json={"query": "test", "db": str(db_path), "top_k": 10}) + assert response.status_code == 404 + assert "Database not found" in response.json()["detail"] + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + @patch("docfinder.web.app.Searcher") + def test_search_success( + self, + mock_searcher_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Returns search results on success.""" + from docfinder.index.search import SearchResult + + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + # Use real SearchResult instead of MagicMock + real_result = SearchResult( + score=0.95, + path="/path/to/doc.pdf", + chunk_index=0, + text="Test content", + title="Test Document", + metadata={}, + ) + + mock_searcher = MagicMock() + mock_searcher.search.return_value = [real_result] + mock_searcher_class.return_value = mock_searcher + + response = client.post("/search", json={"query": "test", "db": str(db_path), "top_k": 10}) + assert response.status_code == 200 + assert "results" in response.json() + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + @patch("docfinder.web.app.Searcher") + def test_search_clamps_top_k( + self, + mock_searcher_class: MagicMock, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Clamps top_k to valid range.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store_class.return_value = mock_store + + mock_searcher = MagicMock() + mock_searcher.search.return_value = [] + mock_searcher_class.return_value = mock_searcher + + # Test with top_k > 50 + response = client.post("/search", json={"query": "test", "db": str(db_path), "top_k": 100}) + assert response.status_code == 200 + mock_searcher.search.assert_called_with("test", top_k=50) + + +class TestOpenEndpoint: + """Tests for POST /open endpoint.""" + + def test_open_file_not_found(self, tmp_path: Path) -> None: + """Returns 404 when file doesn't exist.""" + response = client.post("/open", json={"path": str(tmp_path / "nonexistent.pdf")}) + assert response.status_code == 404 + assert "File not found" in response.json()["detail"] + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX-only test") + @patch("subprocess.Popen") + def test_open_file_success_posix(self, mock_popen: MagicMock, tmp_path: Path) -> None: + """Opens file on POSIX systems.""" + test_file = tmp_path / "test.pdf" + test_file.touch() + + with patch("os.name", "posix"): + with patch("sys.platform", "darwin"): + response = client.post("/open", json={"path": str(test_file)}) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +class TestDocumentsEndpoint: + """Tests for GET /documents endpoint.""" + + def test_documents_database_not_found(self, tmp_path: Path) -> None: + """Returns empty list when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + response = client.get(f"/documents?db={db_path}") + assert response.status_code == 200 + data = response.json() + assert data["documents"] == [] + assert data["stats"]["document_count"] == 0 + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + def test_documents_success( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Returns document list on success.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.list_documents.return_value = [{"id": 1, "path": "/doc.pdf", "title": "Test"}] + mock_store.get_stats.return_value = { + "document_count": 1, + "chunk_count": 5, + "total_size_bytes": 1024, + } + mock_store_class.return_value = mock_store + + response = client.get(f"/documents?db={db_path}") + assert response.status_code == 200 + data = response.json() + assert len(data["documents"]) == 1 + assert data["stats"]["document_count"] == 1 + + +class TestDeleteDocumentEndpoint: + """Tests for DELETE /documents/{doc_id} endpoint.""" + + def test_delete_database_not_found(self, tmp_path: Path) -> None: + """Returns 404 when database doesn't exist.""" + db_path = tmp_path / "nonexistent.db" + response = client.delete(f"/documents/1?db={db_path}") + assert response.status_code == 404 + assert "Database not found" in response.json()["detail"] + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + def test_delete_document_not_found( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Returns 404 when document doesn't exist.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.delete_document.return_value = False + mock_store_class.return_value = mock_store + + response = client.delete(f"/documents/999?db={db_path}") + assert response.status_code == 404 + assert "not found" in response.json()["detail"] + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + def test_delete_document_success( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Successfully deletes document.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.delete_document.return_value = True + mock_store_class.return_value = mock_store + + response = client.delete(f"/documents/1?db={db_path}") + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +class TestDeleteDocumentByPathEndpoint: + """Tests for POST /documents/delete endpoint.""" + + def test_delete_no_identifier(self) -> None: + """Returns 400 when neither doc_id nor path provided.""" + response = client.post("/documents/delete", json={}) + assert response.status_code == 400 + assert "Either doc_id or path" in response.json()["detail"] + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + def test_delete_by_path_success( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Successfully deletes document by path.""" + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.delete_document_by_path.return_value = True + mock_store_class.return_value = mock_store + + response = client.post(f"/documents/delete?db={db_path}", json={"path": "/path/to/doc.pdf"}) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + + +class TestCleanupEndpoint: + """Tests for DELETE /documents/cleanup endpoint.""" + + def test_cleanup_database_not_found(self, tmp_path: Path) -> None: + """Returns 404 when database doesn't exist.""" + from urllib.parse import quote + + db_path = tmp_path / "nonexistent.db" + # URL encode the path to handle special characters + encoded_path = quote(str(db_path), safe="") + response = client.delete(f"/documents/cleanup?db={encoded_path}") + assert response.status_code == 404 + assert "Database not found" in response.json()["detail"] + + @patch("docfinder.web.app.EmbeddingModel") + @patch("docfinder.web.app.SQLiteVectorStore") + def test_cleanup_success( + self, + mock_store_class: MagicMock, + mock_embedder_class: MagicMock, + tmp_path: Path, + ) -> None: + """Successfully removes missing files.""" + from urllib.parse import quote + + db_path = tmp_path / "test.db" + db_path.touch() + + mock_embedder = MagicMock() + mock_embedder.dimension = 768 + mock_embedder_class.return_value = mock_embedder + + mock_store = MagicMock() + mock_store.remove_missing_files.return_value = 2 + mock_store_class.return_value = mock_store + + encoded_path = quote(str(db_path), safe="") + response = client.delete(f"/documents/cleanup?db={encoded_path}") + assert response.status_code == 200 + assert response.json()["removed_count"] == 2 + + +class TestIndexEndpoint: + """Tests for POST /index endpoint.""" + + def test_index_no_paths(self) -> None: + """Returns 400 when no paths provided.""" + response = client.post("/index", json={"paths": []}) + assert response.status_code == 400 + assert "No path provided" in response.json()["detail"] + + def test_index_null_byte_in_path(self) -> None: + """Returns 400 for path with null byte.""" + response = client.post("/index", json={"paths": ["/path/with\x00null"]}) + assert response.status_code == 400 + assert "null byte" in response.json()["detail"] + + def test_index_path_not_found(self, tmp_path: Path) -> None: + """Returns error for nonexistent path (403 if outside home, 404 if inside).""" + # Create path inside home directory that doesn't exist + fake_path = Path.home() / "docfinder_test_nonexistent_12345" + response = client.post("/index", json={"paths": [str(fake_path)]}) + # Should be 404 because path is inside home but doesn't exist + assert response.status_code == 404 + assert "not found" in response.json()["detail"].lower() + + def test_index_path_not_directory(self, tmp_path: Path) -> None: + """Returns 400 when path is a file, not directory.""" + # Create file inside home directory + file_path = Path.home() / "docfinder_test_file_12345.txt" + file_path.touch() + try: + response = client.post("/index", json={"paths": [str(file_path)]}) + assert response.status_code == 400 + assert "must be a directory" in response.json()["detail"] + finally: + file_path.unlink() + + def test_index_path_outside_home(self, tmp_path: Path) -> None: + """Returns 403 for path outside home directory.""" + # Try to access /etc which is definitely outside home + response = client.post("/index", json={"paths": ["/etc"]}) + assert response.status_code == 403 + assert "outside allowed directory" in response.json()["detail"] + + @patch("docfinder.web.app._run_index_job") + def test_index_success( + self, mock_run_index: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Successfully indexes directory.""" + # Create a subdirectory inside home + test_dir = Path.home() / "DocFinder_test_temp" + test_dir.mkdir(exist_ok=True) + + try: + mock_run_index.return_value = { + "inserted": 1, + "updated": 0, + "skipped": 0, + "failed": 0, + "processed_files": [], + } + + response = client.post("/index", json={"paths": [str(test_dir)]}) + assert response.status_code == 200 + assert response.json()["status"] == "ok" + finally: + test_dir.rmdir() + + +class TestFrontendRouter: + """Tests for the frontend HTML routes.""" + + def test_index_page(self) -> None: + """Returns HTML for index page.""" + response = client.get("/") + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "DocFinder" in response.text