Files
graph_explorer/tests/test_python_runtime_resolver.py
egutierrez 7a94160fd2 feat: catch-up de decisiones previas (Webpage→Url, anti-bot, UI 2-col, tests cross-platform)
Bloque de cambios revisados y validados con el usuario en sesiones
previas que no habian aterrizado en commits propios. Lista por tema:

* enrichers: web_search ahora usa lite.duckduckgo.com como endpoint
  primario (mas tolerante con bot detection desde IP residencial),
  con fallback al endpoint html. Detecta pagina captcha y emite
  error claro si ambos fallan. Anyade _DDGLiteParser para el formato
  lite + auto-pick de parser por contenido.

* enrichers: tipo Webpage unificado en Url (campos de cuerpo
  cacheado viven en metadata del Url). Manifests actualizados
  (applies_to: [Url]). fetch_webpage ya no convierte Url->Webpage.

* enrichers/manifest: campo `params` parseado a EnricherSpec.params
  (name, type, default_value, description). UI puede renderizar
  dialog de configuracion.

* jobs: fix de path conversion para Python embebido nativo Windows
  (no convertir a /mnt/c/... cuando el subproceso es Windows-native;
  solo cuando es bash o python via WSL).

* main.cpp: ventana ImGui (no modal) "Run enricher" con layout
  2-col (label izq, input der). Inserta job con JSON tipado. Layout
  clustering apretado: hijos del mismo anchor en un solo anillo
  alrededor del padre, sin desperdigar por anillos crecientes.

* views: inspector con layout 2-col via BeginTable (Identity,
  Schema fields, Extras). Description full-width debajo de su label.

* tests: portable conftest (auto-detecta REGISTRY_ROOT, PYTHON_BIN,
  ENRICHERS_DIR para WSL y Windows portable). _runner.py trampoline
  inyecta stub via sys.path porque embedded Python ignora PYTHONPATH.
  Tests bash-only (vendor_script, freeze, dispatcher bash, resolver
  Linux-binary) skipean en Windows. Tests existentes adaptados a
  Webpage->Url.

Resultado actual: 32 passed WSL, 21 passed + 11 skipped Windows.
2026-05-03 14:41:28 +02:00

148 lines
5.1 KiB
Python

"""Tests del resolver de Python runtime (issue 0033 fase B).
El resolver vive en C++ (jobs.cpp::cached_python_runtime). Como
pytest no puede llamarlo directo, los tests verifican el
comportamiento via efectos observables:
1. La estructura del freeze script y su lock.
2. Que el binario, al arrancar, loggea la procedencia del runtime
(kind=embedded|registry_venv|env|system).
El test del binario solo corre si el .exe esta compilado para Linux
en el path esperado — si no, se skipea.
"""
from __future__ import annotations
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from conftest import REGISTRY_ROOT, APP_DIR_SRC
GRAPH_EXPLORER_BIN = (
REGISTRY_ROOT / "cpp" / "build" / "linux" / "apps"
/ "graph_explorer" / "graph_explorer"
)
def _runtime_log_line(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("[jobs] python runtime:"):
return line
return None
@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(),
reason="binario graph_explorer no compilado")
def test_resolver_falls_back_to_registry_venv_when_no_embed(tmp_path):
"""Sin runtime/ junto al exe, debe caer a registry_venv."""
# Copiamos el binario a tmp_path (sin runtime/) y lo arrancamos
# desde el repo root (donde existe projects/default).
bin_copy = tmp_path / "graph_explorer"
shutil.copy(GRAPH_EXPLORER_BIN, bin_copy)
bin_copy.chmod(0o755)
proc = subprocess.run(
[str(bin_copy)],
cwd=str(REGISTRY_ROOT / "cpp"),
capture_output=True, text=True, timeout=4,
env={**os.environ, "DISPLAY": ""}, # forzamos GLFW fail rapido
)
line = _runtime_log_line(proc.stdout)
assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}"
assert "kind=registry_venv" in line, line
assert "wsl=0" in line, line
@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(),
reason="binario graph_explorer no compilado")
def test_resolver_picks_embedded_when_runtime_present(tmp_path):
"""Con runtime/ junto al exe, debe elegir embedded."""
bin_copy = tmp_path / "graph_explorer"
shutil.copy(GRAPH_EXPLORER_BIN, bin_copy)
bin_copy.chmod(0o755)
# Faux runtime: solo necesita un ejecutable en bin/python3.
rt = tmp_path / "runtime" / "python" / "bin"
rt.mkdir(parents=True)
fake_py = rt / "python3"
fake_py.write_text("#!/bin/bash\necho fake\n")
fake_py.chmod(0o755)
proc = subprocess.run(
[str(bin_copy)],
cwd=str(REGISTRY_ROOT / "cpp"),
capture_output=True, text=True, timeout=4,
env={**os.environ, "DISPLAY": ""},
)
line = _runtime_log_line(proc.stdout)
assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}"
assert "kind=embedded" in line, line
assert str(fake_py) in line, line
@pytest.mark.skipif(not GRAPH_EXPLORER_BIN.exists(),
reason="binario graph_explorer no compilado")
def test_resolver_uses_fn_python_env_var(tmp_path):
"""FN_PYTHON tiene prioridad sobre el venv del registry."""
bin_copy = tmp_path / "graph_explorer"
shutil.copy(GRAPH_EXPLORER_BIN, bin_copy)
bin_copy.chmod(0o755)
fake = tmp_path / "my_python"
fake.write_text("#!/bin/bash\necho fake\n")
fake.chmod(0o755)
proc = subprocess.run(
[str(bin_copy)],
cwd=str(REGISTRY_ROOT / "cpp"),
capture_output=True, text=True, timeout=4,
env={**os.environ, "DISPLAY": "", "FN_PYTHON": str(fake)},
)
line = _runtime_log_line(proc.stdout)
assert line is not None, f"no se logueo runtime: {proc.stdout[-500:]}"
assert "kind=env" in line, line
assert str(fake) in line, line
@pytest.mark.skipif(
shutil.which("bash") is None or
not (APP_DIR_SRC / "tools" / "freeze_python_runtime.sh").exists(),
reason="bash o tools/freeze_python_runtime.sh no disponible "
"(esperado en deploy portable)",
)
def test_freeze_script_is_idempotent(tmp_path):
"""Llamadas consecutivas con mismas deps no rehacen el runtime."""
fake_app = tmp_path / "app"
fake_app.mkdir()
(fake_app / "app.md").write_text(
"---\nname: x\npython_runtime: true\n"
"python_runtime_deps:\n - requests\n---\n",
encoding="utf-8")
script = APP_DIR_SRC / "tools" / "freeze_python_runtime.sh"
# 1ra ejecucion: deberia hacer todo el trabajo.
proc1 = subprocess.run(
[str(script), str(fake_app), "linux"],
capture_output=True, text=True, timeout=120,
)
if proc1.returncode != 0:
pytest.skip(f"freeze fallo (sin uv?): {proc1.stderr[:200]}")
assert (fake_app / "runtime" / ".lock").exists()
lock1 = (fake_app / "runtime" / ".lock").read_text()
# 2da: con el .lock ya escrito, debe reportar "sin cambios".
proc2 = subprocess.run(
[str(script), str(fake_app), "linux"],
capture_output=True, text=True, timeout=10,
)
assert proc2.returncode == 0, proc2.stderr
assert "sin cambios" in proc2.stdout, proc2.stdout
lock2 = (fake_app / "runtime" / ".lock").read_text()
assert lock1 == lock2