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:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
"""Trampoline para invocar enrichers desde tests.
|
||||
|
||||
El Python embebido de Windows (`python-embed`) ignora `PYTHONPATH` por
|
||||
diseno — el control de sys.path lo lleva el fichero `python312._pth`.
|
||||
Para inyectar el stub `requests` de tests sin tocar ese fichero, los
|
||||
tests llaman a este runner en vez de a `run.py` directamente:
|
||||
|
||||
python _runner.py <run.py>
|
||||
|
||||
El runner anade `$_STUB_PATHS` al frente de `sys.path` y ejecuta el
|
||||
script objetivo como si hubiese sido invocado directamente.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
|
||||
|
||||
def main() -> int:
|
||||
stub_paths = os.environ.get("_STUB_PATHS", "")
|
||||
if stub_paths:
|
||||
for p in stub_paths.split(os.pathsep):
|
||||
if p and p not in sys.path:
|
||||
sys.path.insert(0, p)
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("usage: _runner.py <script>\n")
|
||||
return 2
|
||||
target = sys.argv[1]
|
||||
sys.argv = [target] + sys.argv[2:]
|
||||
runpy.run_path(target, run_name="__main__")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+93
-5
@@ -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,
|
||||
|
||||
@@ -12,7 +12,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
@@ -130,12 +132,17 @@ def test_python_dummy_enricher_obeys_wire_protocol(tmp_path):
|
||||
# Wire protocol — Bash (la ruta nueva)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.platform.startswith("win") or not shutil.which("bash"),
|
||||
reason="test bash-only — saltado en Windows (el bash de WSL no acepta "
|
||||
"rutas Windows nativas) y en sistemas sin bash",
|
||||
)
|
||||
def test_bash_dummy_enricher_obeys_wire_protocol(tmp_path):
|
||||
enr = _write_dummy_enricher(tmp_path, eid="dummy_sh", lang="bash")
|
||||
|
||||
ctx = json.dumps({"node_id": "n1", "ops_db_path": "", "params": {}})
|
||||
proc = subprocess.run(
|
||||
["/bin/bash", str(enr / "run.sh")],
|
||||
[shutil.which("bash"), str(enr / "run.sh")],
|
||||
input=ctx, capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
|
||||
@@ -31,10 +31,10 @@ def test_extract_links_creates_url_nodes(ops_db, app_dir, registry_root):
|
||||
|
||||
# 2) Crear Webpage con metadata.markdown_path apuntando al cache.
|
||||
make_node(ops_db, node_id="w1", name="demo",
|
||||
type_ref="Webpage", metadata={"markdown_path": str(rel)})
|
||||
type_ref="Url", metadata={"markdown_path": str(rel)})
|
||||
|
||||
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
|
||||
node_id="w1", node_name="demo", node_type="Webpage",
|
||||
node_id="w1", node_name="demo", node_type="Url",
|
||||
metadata={"markdown_path": str(rel)})
|
||||
|
||||
rc, out, err = run_enricher("extract_links", ctx)
|
||||
@@ -54,9 +54,9 @@ def test_extract_links_creates_url_nodes(ops_db, app_dir, registry_root):
|
||||
def test_extract_links_without_markdown_path_errors(ops_db, app_dir,
|
||||
registry_root):
|
||||
make_node(ops_db, node_id="w1", name="demo",
|
||||
type_ref="Webpage", metadata={})
|
||||
type_ref="Url", metadata={})
|
||||
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
|
||||
node_id="w1", node_name="demo", node_type="Webpage")
|
||||
node_id="w1", node_name="demo", node_type="Url")
|
||||
rc, out, err = run_enricher("extract_links", ctx)
|
||||
assert rc != 0, "deberia fallar sin markdown_path"
|
||||
assert out is not None
|
||||
|
||||
@@ -27,9 +27,9 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
|
||||
rel = md_path.relative_to(app_dir)
|
||||
|
||||
make_node(ops_db, node_id="w1", name="report",
|
||||
type_ref="Webpage", metadata={"markdown_path": str(rel)})
|
||||
type_ref="Url", metadata={"markdown_path": str(rel)})
|
||||
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
|
||||
node_id="w1", node_name="report", node_type="Webpage",
|
||||
node_id="w1", node_name="report", node_type="Url",
|
||||
metadata={"markdown_path": str(rel)})
|
||||
|
||||
rc, out, err = run_enricher("extract_text_entities", ctx)
|
||||
@@ -38,7 +38,7 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
|
||||
assert out["entities_added"] >= 3, out
|
||||
|
||||
types = {e["type_ref"] for e in list_entities(ops_db)
|
||||
if e["type_ref"] != "Webpage"}
|
||||
if e["type_ref"] != "Url"}
|
||||
# No exigimos todos los tipos — depende de que extract_iocs cubra cada
|
||||
# patron — pero al menos Email y CVE deberian estar.
|
||||
assert "Email" in types, types
|
||||
@@ -51,9 +51,9 @@ def test_extract_iocs_creates_typed_entities(ops_db, app_dir, registry_root):
|
||||
|
||||
def test_extract_iocs_without_markdown_errors(ops_db, app_dir, registry_root):
|
||||
make_node(ops_db, node_id="w1", name="empty",
|
||||
type_ref="Webpage", metadata={})
|
||||
type_ref="Url", metadata={})
|
||||
ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root,
|
||||
node_id="w1", node_name="empty", node_type="Webpage")
|
||||
node_id="w1", node_name="empty", node_type="Url")
|
||||
rc, out, err = run_enricher("extract_text_entities", ctx)
|
||||
assert rc != 0
|
||||
assert out and "missing markdown_path" in (out.get("error") or "")
|
||||
|
||||
@@ -42,9 +42,9 @@ def test_fetch_webpage_creates_domain_and_caches(ops_db, app_dir, registry_root,
|
||||
assert out["entities_added"] == 1 # Domain
|
||||
assert out["relations_added"] == 1 # BELONGS_TO
|
||||
|
||||
# El nodo Url se promueve a Webpage.
|
||||
# El nodo Url permanece como Url (Webpage se unifico en Url).
|
||||
e = get_entity(ops_db, "u1")
|
||||
assert e["type_ref"] == "Webpage", e
|
||||
assert e["type_ref"] == "Url", e
|
||||
assert e["metadata"]["title"] == "Acme Demo"
|
||||
assert e["metadata"]["status_code"] == 200
|
||||
|
||||
|
||||
@@ -109,6 +109,12 @@ def test_resolver_uses_fn_python_env_var(tmp_path):
|
||||
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"
|
||||
|
||||
@@ -23,6 +23,17 @@ from conftest import APP_DIR_SRC, REGISTRY_ROOT
|
||||
|
||||
SCRIPT = APP_DIR_SRC / "tools" / "vendor_enricher_python.sh"
|
||||
|
||||
# El script vendor es bash-only y vive en el repo dev. En la carpeta
|
||||
# portable de Windows no esta presente; ademas necesitaria un bash
|
||||
# real para ejecutarse. Saltamos toda la suite si:
|
||||
# - no encontramos `bash` en PATH (Windows), o
|
||||
# - el script no existe (deploy portable sin tools/).
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("bash") is None or not SCRIPT.exists(),
|
||||
reason="bash o tools/vendor_enricher_python.sh no disponible "
|
||||
"(esperado en deploy portable)",
|
||||
)
|
||||
|
||||
|
||||
def _make_enricher_dir(tmp_path: Path, manifest: str) -> Path:
|
||||
enr = tmp_path / "test_enricher"
|
||||
|
||||
Reference in New Issue
Block a user