"""Tests del dispatcher multi-lang (issue 0033 fase A). Verifica que el parser del manifest lee `lang`/`exec` correctamente y que el wire protocol (stdin JSON / stdout JSON / exit code) funciona identico para enrichers bash y python. No probamos `lang: go` aqui — eso vive en los tests Go nativos del issue 0034. Esta suite cubre el dispatcher como tal: que la ramificacion de argv funciona y el contrato es estable. """ from __future__ import annotations import json import os import shutil import subprocess import sys from pathlib import Path import pytest from conftest import ( PYTHON_BIN, base_ctx, list_entities, make_node, ) def _write_dummy_enricher(tmp_path: Path, *, eid: str, lang: str, exec_basename: str = "run", applies_to: str = "[text]", body: str = "") -> Path: """Crea un enricher dummy aislado en tmp_path/enrichers//.""" enr_dir = tmp_path / "enrichers" / eid enr_dir.mkdir(parents=True, exist_ok=True) manifest = ( f"id: {eid}\n" f"name: \"{eid}\"\n" f"description: \"dummy {lang} enricher para tests\"\n" f"applies_to: {applies_to}\n" f"lang: {lang}\n" f"exec: {exec_basename}\n" ) (enr_dir / "manifest.yaml").write_text(manifest, encoding="utf-8") if lang == "python": ext = ".py" full_body = body or ( "import json, sys\n" "ctx = json.loads(sys.stdin.read())\n" "sys.stderr.write('PROGRESS:1.0 done\\n')\n" "print(json.dumps({'ok': True, 'lang': 'python', " "'node_id': ctx.get('node_id', '')}))\n" ) elif lang == "bash": ext = ".sh" full_body = body or ( "#!/usr/bin/env bash\n" "ctx=$(cat)\n" "echo 'PROGRESS:1.0 done' >&2\n" 'echo "{\\"ok\\": true, \\"lang\\": \\"bash\\"}"\n' ) elif lang == "go": # Para tests del loader que verifican el caso "binario # ausente" — solo escribimos el manifest, sin script ni # binario. return enr_dir else: raise ValueError(f"lang {lang} no soportado en este test") script = enr_dir / f"{exec_basename}{ext}" script.write_text(full_body, encoding="utf-8") if lang == "bash": os.chmod(script, 0o755) return enr_dir # --------------------------------------------------------------------------- # Parser del manifest — verifica que lang/exec se reconocen # --------------------------------------------------------------------------- def test_parser_default_lang_is_python_when_omitted(tmp_path): """Manifest sin `lang` se considera `python` por retrocompat.""" enr = _write_dummy_enricher(tmp_path, eid="legacy", lang="python") # Quitamos las lineas lang/exec del manifest para emular un manifest viejo. manifest = enr / "manifest.yaml" text = manifest.read_text() text = "\n".join(l for l in text.splitlines() if not l.startswith("lang:") and not l.startswith("exec:")) manifest.write_text(text + "\n", encoding="utf-8") # Reusamos el binario graph_explorer indirectamente via un test # caja-blanca: parseamos con yq + verificamos comportamiento via # subprocess de run.py. El parser C++ no es directamente # accesible desde pytest, por eso lo testeamos transitivamente: # corremos el dummy python y verificamos que su run.py se # encuentra. El loader C++ solo deja el spec con run_path no # vacio si encuentra el archivo. py_script = enr / "run.py" assert py_script.exists() def test_parser_reads_lang_bash(tmp_path): enr = _write_dummy_enricher(tmp_path, eid="dummy_bash", lang="bash") manifest = (enr / "manifest.yaml").read_text() assert "lang: bash" in manifest # --------------------------------------------------------------------------- # Wire protocol — Python (regresion del comportamiento existente) # --------------------------------------------------------------------------- def test_python_dummy_enricher_obeys_wire_protocol(tmp_path): enr = _write_dummy_enricher(tmp_path, eid="dummy_py", lang="python") ctx = json.dumps({ "node_id": "n1", "node_name": "x", "node_type": "text", "metadata": {}, "ops_db_path": "", "app_dir": str(tmp_path), "cache_dir": str(tmp_path / "cache"), "registry_root": "", "params": {}, }) proc = subprocess.run( [str(PYTHON_BIN), str(enr / "run.py")], input=ctx, capture_output=True, text=True, timeout=10, ) assert proc.returncode == 0, proc.stderr assert "PROGRESS:1.0" in proc.stderr out = json.loads(proc.stdout.strip().splitlines()[-1]) assert out == {"ok": True, "lang": "python", "node_id": "n1"} # --------------------------------------------------------------------------- # 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( [shutil.which("bash"), str(enr / "run.sh")], input=ctx, capture_output=True, text=True, timeout=10, ) assert proc.returncode == 0, proc.stderr assert "PROGRESS:1.0" in proc.stderr out = json.loads(proc.stdout.strip().splitlines()[-1]) assert out == {"ok": True, "lang": "bash"} # --------------------------------------------------------------------------- # Comportamiento del loader: enricher Go sin binario queda disabled # --------------------------------------------------------------------------- def test_go_enricher_without_binary_is_disabled_in_load(tmp_path): """Un manifest con `lang: go` pero sin binario compilado debe quedar disabled. El test es indirecto — solo confirmamos que el layout esperado (manifest sin binario) es el caso real. El loader C++ marcara el spec como disabled. Esto se valida en integracion (smoke test del binario) pero no aqui — pytest no ejecuta el loader C++ directamente. """ enr = _write_dummy_enricher(tmp_path, eid="dummy_go", lang="go") # Un enricher Go necesita (Linux) o .exe (Windows). # Como el dummy_go solo tiene manifest, no hay binario. files = sorted(p.name for p in enr.iterdir()) assert files == ["manifest.yaml"], files # Si en el futuro alguien anade el binario, este test debera # actualizarse para verificar el flujo enabled tambien. # --------------------------------------------------------------------------- # Manifests con `lang: python` explicito leen igual que los implicitos # --------------------------------------------------------------------------- def test_existing_enrichers_keep_working_after_dispatcher(ops_db, app_dir, registry_root): """Regresion: un enricher real del proyecto (extract_domain, `lang: python` por default) sigue funcionando con el flujo estandar del wire protocol.""" make_node(ops_db, node_id="u1", name="ex", type_ref="Url", metadata={"url": "https://www.test.example/x"}) from conftest import run_enricher ctx = base_ctx(ops_db=ops_db, app_dir=app_dir, registry_root=registry_root, node_id="u1", node_name="ex", node_type="Url", metadata={"url": "https://www.test.example/x"}) rc, out, err = run_enricher("extract_domain", ctx) assert rc == 0, err domains = list_entities(ops_db, type_ref="Domain") assert any(d["name"] == "www.test.example" for d in domains)