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.
This commit is contained in:
2026-05-03 14:41:28 +02:00
parent 4be5734ce5
commit 7a94160fd2
26 changed files with 973 additions and 241 deletions
+93 -5
View File
@@ -23,24 +23,106 @@ from pathlib import Path
import pytest
REGISTRY_ROOT = Path(__file__).resolve().parents[5]
APP_DIR_SRC = Path(__file__).resolve().parents[1] # graph_explorer/
ENRICHERS_DIR = APP_DIR_SRC / "enrichers"
TESTS_DIR = Path(__file__).resolve().parent
STUBS_DIR = TESTS_DIR / "_stubs"
PYTHON_BIN = REGISTRY_ROOT / "python" / ".venv" / "bin" / "python3"
# Los enrichers viven en `<app>/enrichers/` en el repo dev y en
# `<app>/assets/enrichers/` en la carpeta portable de Windows
# (convencion `assets/` desde el ADR de feb-2026). Detectar cual
# existe y usar ese.
def _resolve_enrichers_dir() -> Path:
cands = [
APP_DIR_SRC / "enrichers",
APP_DIR_SRC / "assets" / "enrichers",
]
for c in cands:
if c.is_dir():
return c
# Default a la primera para mensajes de error consistentes con el dev layout.
return cands[0]
ENRICHERS_DIR = _resolve_enrichers_dir()
def _resolve_registry_root() -> Path:
"""Sube desde el directorio de tests buscando un marker del registry.
En el repo: APP_DIR/projects/osint_graph/apps/graph_explorer/tests
-> 5 niveles arriba esta fn_registry/. En la carpeta de Windows
(Desktop/apps/graph_explorer/tests) NO hay registry — usamos el
propio app dir como fallback. Los tests no leen registry.db; solo
se pasa registry_root via ctx por compatibilidad con run.py.
"""
# Marker fiable: fichero `cmd/fn/main.go` o `registry.db`.
p = APP_DIR_SRC
for _ in range(8):
if (p / "cmd" / "fn" / "main.go").exists() or \
(p / "registry.db").exists():
return p
if p.parent == p:
break
p = p.parent
# Sin registry: usa el app dir como pseudo-root. Los tests funcionan
# igual mientras no haya un test que importe paquetes del registry.
return APP_DIR_SRC
REGISTRY_ROOT = _resolve_registry_root()
def _resolve_python_bin() -> Path:
"""Elige el Python con el que ejecutar los enrichers.
Prioridad (cubre Linux/WSL dev y Windows portable instalado):
1. $FN_TEST_PYTHON env override
2. <app>/assets/runtime/python/python.exe (Windows portable, solo Windows)
3. <app>/runtime/python/python.exe (legacy, solo Windows)
4. <registry>/python/.venv/bin/python3 (WSL dev venv)
5. sys.executable (whatever runs pytest)
Los candidatos `python.exe` solo se aceptan si corremos en Windows
nativo. En WSL/Linux pueden existir vendored en el repo (los
distribuibles), pero no son ejecutables en este OS.
"""
env = os.environ.get("FN_TEST_PYTHON")
if env and Path(env).exists():
return Path(env)
is_windows = sys.platform.startswith("win")
cands: list[Path] = []
if is_windows:
cands += [
APP_DIR_SRC / "assets" / "runtime" / "python" / "python.exe",
APP_DIR_SRC / "runtime" / "python" / "python.exe",
]
cands += [REGISTRY_ROOT / "python" / ".venv" / "bin" / "python3"]
for c in cands:
if c.exists():
return c
return Path(sys.executable)
PYTHON_BIN = _resolve_python_bin()
def stub_requests(tmp_path: Path, plan: dict) -> dict:
"""Escribe el plan de respuestas y devuelve el env que activa el stub.
El stub vive en tests/_stubs/requests.py y se activa via PYTHONPATH.
Devuelve dos vias por las que `_runner.py` y un Python no-embedded
pueden inyectar el stub:
- `PYTHONPATH`: la ruta estandar; respeta el orden y el resto del
entorno. Funciona en Linux y en Python full instalado (no-embed).
- `_STUB_PATHS`: lo lee `_runner.py` y hace `sys.path.insert(0, ...)`.
Necesario en el Python embebido de Windows, que ignora
PYTHONPATH (lo controla `python312._pth`).
Plan acepta `default` y/o `match` (lista de {contains, status, text}).
"""
plan_file = tmp_path / "_stub_plan.json"
plan_file.write_text(json.dumps(plan), encoding="utf-8")
return {
"PYTHONPATH": str(STUBS_DIR) + os.pathsep + os.environ.get("PYTHONPATH", ""),
"_STUB_PATHS": str(STUBS_DIR),
"_STUB_REQUESTS_PLAN": str(plan_file),
}
@@ -189,17 +271,23 @@ def run_enricher(enricher_id: str, ctx: dict, *, env: dict | None = None,
timeout: int = 30) -> tuple[int, dict | None, str]:
"""Lanza enrichers/<id>/run.py con el wire protocol estandar.
Usa siempre el trampoline `_runner.py` para que el stub de
requests se inyecte tanto con PYTHONPATH (Python normal) como con
`_STUB_PATHS` (Python embebido de Windows que ignora PYTHONPATH).
Returns: (exit_code, stdout_json_or_None, stderr_text)
"""
run_py = ENRICHERS_DIR / enricher_id / "run.py"
assert run_py.exists(), f"no existe {run_py}"
runner = TESTS_DIR / "_runner.py"
assert runner.exists(), f"no existe {runner}"
full_env = os.environ.copy()
if env:
full_env.update(env)
proc = subprocess.run(
[str(PYTHON_BIN), str(run_py)],
[str(PYTHON_BIN), str(runner), str(run_py)],
input=json.dumps(ctx),
capture_output=True,
text=True,