open-notebook/tests/test_capabilities_api.py
Luis Novo 3b7243d216
Some checks are pending
Development Build / extract-version (push) Waiting to run
Development Build / changes (push) Waiting to run
Tests / Frontend Lint (push) Waiting to run
Tests / Backend Tests (push) Waiting to run
Tests / Backend Lint (push) Waiting to run
Tests / Backend Typecheck (push) Waiting to run
Development Build / build-regular (push) Blocked by required conditions
Development Build / build-single (push) Blocked by required conditions
Development Build / summary (push) Blocked by required conditions
Tests / Frontend Tests (push) Waiting to run
Tests / Frontend Build (push) Waiting to run
fix(sources): fall back to auto when a selected engine's runtime is absent (#1194)
* fix(sources): fall back to auto when a selected engine's runtime is absent

The content-processing engine choice is persisted in the database; the
runtime that serves it (Docling, local Crawl4AI) is installed on demand
from environment flags evaluated at boot. The two therefore drift: a
redeploy that drops OPEN_NOTEBOOK_ENABLE_CRAWL4AI/_DOCLING, a volume
moved to a new deployment, or a failed on-demand install all leave a
stored selection pointing at a runtime that is not there.

The source graph passed that selection straight to content-core, so
every affected extraction failed with "Could not extract any text
content from this source" - no mention of the engine, the runtime, or
the flag that would fix it. For a URL engine set to crawl4ai this breaks
URL ingestion entirely.

The graph now checks runtime availability before honoring the stored
engine and degrades to content-core's "auto" chain, logging a WARNING
that names the engine and the env var that would enable it. Engines with
no opt-in runtime (auto/simple/firecrawl/jina) are passed through
untouched.

The availability probes moved from api/routers/capabilities.py to
open_notebook/utils/runtime_capabilities.py so the graph can use them
without importing from the API layer; the capabilities endpoint keeps
identical behavior and its tests follow the probes to their new home.

Found by the smoke-e2e agent during v1.14.0 release testing, on a dev
environment that was in exactly this state. Pre-existing since v1.13.0
(#1122 made the runtimes opt-in, #432 made the stored selection take
effect), not a v1.14.0 regression.

* docs(changelog): record the unavailable-engine fallback fix
2026-07-20 18:09:00 -03:00

128 lines
4.8 KiB
Python

"""
Tests for GET /api/capabilities (api/routers/capabilities.py).
The endpoint reports the *actual* availability of the opt-in heavy extraction
runtimes (Docling, Crawl4AI local) so the frontend can gate engine options.
These tests lock the composition rule: crawl4ai_available is true when EITHER a
local package is installed OR a remote server is configured.
"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture
def client():
from api.main import app
return TestClient(app)
def _patch_probes(monkeypatch, *, docling, crawl4ai_local, crawl4ai_remote):
monkeypatch.setattr(
"api.routers.capabilities.docling_available", lambda: docling
)
monkeypatch.setattr(
"api.routers.capabilities.crawl4ai_remote_configured",
lambda: crawl4ai_remote,
)
# Local readiness means package installed AND a Chromium browser present.
monkeypatch.setattr(
"api.routers.capabilities.crawl4ai_local_ready", lambda: crawl4ai_local
)
class TestCapabilitiesEndpoint:
def test_all_unavailable(self, client, monkeypatch):
_patch_probes(
monkeypatch, docling=False, crawl4ai_local=False, crawl4ai_remote=False
)
response = client.get("/api/capabilities")
assert response.status_code == 200
assert response.json() == {
"docling_available": False,
"crawl4ai_available": False,
"crawl4ai_remote_configured": False,
}
def test_docling_available_is_independent_of_crawl4ai(self, client, monkeypatch):
_patch_probes(
monkeypatch, docling=True, crawl4ai_local=False, crawl4ai_remote=False
)
body = client.get("/api/capabilities").json()
assert body["docling_available"] is True
assert body["crawl4ai_available"] is False
def test_local_crawl4ai_makes_it_available(self, client, monkeypatch):
_patch_probes(
monkeypatch, docling=False, crawl4ai_local=True, crawl4ai_remote=False
)
body = client.get("/api/capabilities").json()
assert body["crawl4ai_available"] is True
assert body["crawl4ai_remote_configured"] is False
def test_remote_crawl4ai_makes_it_available_without_local(
self, client, monkeypatch
):
_patch_probes(
monkeypatch, docling=False, crawl4ai_local=False, crawl4ai_remote=True
)
body = client.get("/api/capabilities").json()
assert body["crawl4ai_available"] is True
assert body["crawl4ai_remote_configured"] is True
class TestCrawl4aiLocalReadiness:
"""Local Crawl4AI needs the package AND a Chromium browser on disk."""
def test_not_ready_when_package_missing(self, monkeypatch):
import open_notebook.utils.runtime_capabilities as cap
monkeypatch.setattr(
cap.importlib.util, "find_spec", lambda name, *a, **k: None
)
assert cap.crawl4ai_local_ready() is False
def test_not_ready_when_browser_missing(self, monkeypatch, tmp_path):
import open_notebook.utils.runtime_capabilities as cap
monkeypatch.setattr(
cap.importlib.util, "find_spec", lambda name, *a, **k: object()
)
# PLAYWRIGHT_BROWSERS_PATH set to an empty dir → no chromium installed.
monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path))
assert cap.crawl4ai_local_ready() is False
def test_ready_when_browser_present(self, monkeypatch, tmp_path):
import open_notebook.utils.runtime_capabilities as cap
monkeypatch.setattr(
cap.importlib.util, "find_spec", lambda name, *a, **k: object()
)
(tmp_path / "chromium-1140").mkdir()
monkeypatch.setenv("PLAYWRIGHT_BROWSERS_PATH", str(tmp_path))
assert cap.crawl4ai_local_ready() is True
def test_dev_default_cache_without_browser_is_not_ready(
self, monkeypatch, tmp_path
):
"""No PLAYWRIGHT_BROWSERS_PATH: fall back to the default cache, fail closed if empty."""
import open_notebook.utils.runtime_capabilities as cap
monkeypatch.setattr(
cap.importlib.util, "find_spec", lambda name, *a, **k: object()
)
monkeypatch.delenv("PLAYWRIGHT_BROWSERS_PATH", raising=False)
monkeypatch.setattr(cap, "_default_playwright_cache", lambda: str(tmp_path))
assert cap.crawl4ai_local_ready() is False
def test_dev_default_cache_with_browser_is_ready(self, monkeypatch, tmp_path):
import open_notebook.utils.runtime_capabilities as cap
monkeypatch.setattr(
cap.importlib.util, "find_spec", lambda name, *a, **k: object()
)
monkeypatch.delenv("PLAYWRIGHT_BROWSERS_PATH", raising=False)
(tmp_path / "chromium-1140").mkdir()
monkeypatch.setattr(cap, "_default_playwright_cache", lambda: str(tmp_path))
assert cap.crawl4ai_local_ready() is True