aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
authorElvis Claros Castro <elvis@claros.ar>2026-08-16 19:57:17 -0300
committerElvis Claros Castro <elvis@claros.ar>2026-08-16 19:57:17 -0300
commitbd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f (patch)
treea9e0dd65940de54c8f956befe9d86b80350bde2e /tests
parentda1c60458b0d28e84739475491a3f2d61ed8eff8 (diff)
downloadclaude-logbook-bd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f.tar.gz
claude-logbook-bd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f.zip
Preparar el proyecto para publicarlo
El script suelto pasa a ser un paquete instalable, con tests y documentación. El comportamiento del CLI no cambia: mismos flags, misma salida. Estructura - claude_sesiones/{sessions,terminal,webpage,cli}.py separa parseo, presentación, generación del HTML y argumentos. El template pasa a ser data del paquete. - pyproject.toml con el entry point claude-sesiones, sin dependencias. - build.sh se disuelve en `claude-sesiones --html [ARCHIVO]`: el HTML se arma en proceso, sin subprocess ni data.json intermedio. `--open` lo abre en el navegador. Arreglos - El template no tenía doctype ni <meta charset>: abierto como file:// quedaba en quirks mode y con la codificación del sistema, así que los acentos salían rotos. Tampoco tenía viewport, con lo que en el celular se veía a escala de escritorio. - `delete_sessions()` se llamaba con un argumento de menos (lo encontró el test de borrado). - El orden de la tabla comparaba timestamps como strings; ahora compara los datetimes ya parseados. - `mark()` buscaba el texto crudo dentro del HTML ya escapado, así que resaltar algo con & o < nunca encontraba nada. `esc()` no escapaba la comilla simple. - El caché no tenía versión: al cambiar el esquema del registro se leían registros de la forma anterior. Ahora se invalida solo. El temporal lleva el pid, para que dos corridas simultáneas no se pisen. - `pick()` cortaba el proceso con sys.exit desde adentro; ahora levanta SessionError y el código de salida lo decide la CLI. Mejoras - Respeta CLAUDE_CONFIG_DIR. - La página tiene botón de tema claro/oscuro que recuerda la elección, deep links (#uuid abre esa conversación), atajos de teclado, trampa de foco en el lector y aviso si el JS está apagado. Las tres copias de la paleta quedaron en dos, una por tema. - 92 tests con unittest, sin dependencias, contra árboles de .jsonl falsos: nunca tocan ~/.claude. CI en GitHub Actions, Python 3.9 a 3.13. - README en inglés y español, con el esquema del JSON y una advertencia sobre lo que hay adentro de sesiones.html. - Apache-2.0. Claude-Session: https://claude.ai/code/session_01RmtZ9qBemrc9TncwVTG6ED
Diffstat (limited to 'tests')
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/fixtures.py70
-rw-r--r--tests/test_cli.py211
-rw-r--r--tests/test_sessions.py307
-rw-r--r--tests/test_terminal.py142
-rw-r--r--tests/test_webpage.py92
6 files changed, 822 insertions, 0 deletions
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/fixtures.py b/tests/fixtures.py
new file mode 100644
index 0000000..9b55c4b
--- /dev/null
+++ b/tests/fixtures.py
@@ -0,0 +1,70 @@
+"""Sesiones .jsonl de mentira para los tests."""
+
+import json
+import os
+
+BASE_TS = "2025-08-14T10:00:00.000Z"
+
+
+def ts(minute=0, hour=10, day=14):
+ return f"2025-08-{day:02d}T{hour:02d}:{minute:02d}:00.000Z"
+
+
+def user(text, at=BASE_TS, **extra):
+ ev = {
+ "type": "user",
+ "timestamp": at,
+ "cwd": "/home/u/proj",
+ "gitBranch": "main",
+ "version": "1.0.0",
+ "message": {"role": "user", "content": [{"type": "text", "text": text}]},
+ }
+ ev.update(extra)
+ return ev
+
+
+def assistant(text=None, tools=(), at=BASE_TS, **extra):
+ content = []
+ if text is not None:
+ content.append({"type": "text", "text": text})
+ for name, args in tools:
+ content.append({"type": "tool_use", "name": name, "input": args})
+ ev = {
+ "type": "assistant",
+ "timestamp": at,
+ "message": {"role": "assistant", "content": content},
+ }
+ ev.update(extra)
+ return ev
+
+
+def ai_title(title, at=BASE_TS):
+ return {"type": "ai-title", "aiTitle": title, "timestamp": at}
+
+
+def write_session(root, project_dir, session_id, events):
+ """Escribe un .jsonl y devuelve su ruta."""
+ d = os.path.join(root, project_dir)
+ os.makedirs(d, exist_ok=True)
+ path = os.path.join(d, session_id + ".jsonl")
+ with open(path, "w", encoding="utf-8") as f:
+ for ev in events:
+ f.write(json.dumps(ev, ensure_ascii=False) + "\n")
+ return path
+
+
+def simple_tree(root):
+ """Un árbol chico y variado: una charla, una vacía y una sin cwd."""
+ write_session(root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", [
+ ai_title("Arreglar el build"),
+ user("¿por qué falla el build?", at=ts(0)),
+ assistant("Miro el log.", tools=[("Bash", {"command": "make"})], at=ts(3)),
+ user("gracias", at=ts(12)),
+ ])
+ write_session(root, "-home-u-proj", "bbbbbbbb-0000-0000-0000-000000000002", [
+ {"type": "system", "timestamp": ts(0, hour=9), "cwd": "/home/u/proj"},
+ ])
+ write_session(root, "-home-u-otro", "cccccccc-0000-0000-0000-000000000003", [
+ user("hola", at=ts(0, hour=8), cwd="/home/u/otro"),
+ ])
+ return root
diff --git a/tests/test_cli.py b/tests/test_cli.py
new file mode 100644
index 0000000..91be284
--- /dev/null
+++ b/tests/test_cli.py
@@ -0,0 +1,211 @@
+import io
+import json
+import os
+import re
+import tempfile
+import unittest
+import unittest.mock
+from contextlib import redirect_stderr, redirect_stdout
+
+from claude_sesiones import cli
+
+from .fixtures import simple_tree, ts, user, write_session
+
+PAYLOAD_RE = re.compile(
+ r'<script id="payload" type="application/json">(.*?)</script>', re.S)
+
+
+class CliCase(unittest.TestCase):
+ """Cada test corre contra un ~/.claude y un caché de mentira."""
+
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.addCleanup(self._tmp.cleanup)
+ self.home = self._tmp.name
+ self.root = os.path.join(self.home, ".claude", "projects")
+ os.makedirs(self.root)
+ env = unittest.mock.patch.dict(os.environ, {
+ "CLAUDE_CONFIG_DIR": os.path.join(self.home, ".claude"),
+ "XDG_CACHE_HOME": os.path.join(self.home, "cache"),
+ "NO_COLOR": "1",
+ })
+ env.start()
+ self.addCleanup(env.stop)
+
+ def run_cli(self, *argv):
+ out, err = io.StringIO(), io.StringIO()
+ with redirect_stdout(out), redirect_stderr(err):
+ code = cli.main(list(argv))
+ return code, out.getvalue(), err.getvalue()
+
+
+class TestTabla(CliCase):
+ def test_lista_las_sesiones(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli()
+ self.assertEqual(code, 0)
+ self.assertIn("Arreglar el build", out)
+ self.assertIn("3 sesiones · 2 proyectos", out)
+
+ def test_filtra_por_texto(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("arreglar")
+ self.assertEqual(code, 0)
+ self.assertIn("1 de 3 sesiones", out)
+
+ def test_limita_la_cantidad(self):
+ simple_tree(self.root)
+ _, out, _ = self.run_cli("-n", "1")
+ self.assertIn("1 de 3 sesiones", out)
+
+ def test_un_filtro_sin_resultados_sale_con_1(self):
+ simple_tree(self.root)
+ code, _, err = self.run_cli("no-existe-esto")
+ self.assertEqual(code, 1)
+ self.assertIn("Ninguna sesión coincide", err)
+
+ def test_sin_directorio_de_claude_sale_con_2(self):
+ with unittest.mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/no/existe"}):
+ code, _, err = self.run_cli()
+ self.assertEqual(code, 2)
+ self.assertIn("no existe", err)
+
+ def test_sin_ninguna_sesion_sale_con_1(self):
+ code, _, err = self.run_cli()
+ self.assertEqual(code, 1)
+ self.assertIn("No hay ninguna sesión", err)
+
+
+class TestExportar(CliCase):
+ def test_json(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("--json")
+ data = json.loads(out)
+ self.assertEqual(code, 0)
+ self.assertEqual(len(data), 3)
+ self.assertNotIn("project_dir", data[0])
+
+ def test_html(self):
+ simple_tree(self.root)
+ out_path = os.path.join(self.home, "s.html")
+ code, _, err = self.run_cli("--html", out_path)
+ self.assertEqual(code, 0)
+ self.assertIn("3 sesiones", err)
+ with open(out_path, encoding="utf-8") as f:
+ html = f.read()
+ self.assertEqual(len(json.loads(PAYLOAD_RE.findall(html)[0])), 3)
+
+ def test_html_no_toca_stdout(self):
+ # El resumen va a stderr para que `--html /dev/stdout` siga sirviendo.
+ simple_tree(self.root)
+ _, out, _ = self.run_cli("--html", os.path.join(self.home, "s.html"))
+ self.assertEqual(out, "")
+
+
+class TestLectura(CliCase):
+ def test_show_por_indice(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("-s", "1", "--no-pager")
+ self.assertEqual(code, 0)
+ self.assertIn("¿por qué falla el build?", out)
+
+ def test_show_por_prefijo_de_uuid(self):
+ simple_tree(self.root)
+ _, out, _ = self.run_cli("-s", "cccccccc", "--no-pager")
+ self.assertIn("hola", out)
+
+ def test_show_respeta_el_filtro_previo(self):
+ simple_tree(self.root)
+ _, out, _ = self.run_cli("-p", "/home/u/otro", "-s", "1", "--no-pager")
+ self.assertIn("hola", out)
+
+ def test_una_referencia_que_no_existe_sale_con_2(self):
+ simple_tree(self.root)
+ code, _, err = self.run_cli("-s", "99")
+ self.assertEqual(code, 2)
+ self.assertIn("fuera de rango", err)
+
+ def test_resume_imprime_el_comando(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("-r", "1")
+ self.assertEqual(code, 0)
+ self.assertEqual(out.strip(),
+ "cd /home/u/proj && claude --resume "
+ "aaaaaaaa-0000-0000-0000-000000000001")
+
+
+class TestBorrado(CliCase):
+ def paths(self):
+ return sorted(os.listdir(os.path.join(self.root, "-home-u-proj")))
+
+ def test_dry_run_no_toca_nada(self):
+ simple_tree(self.root)
+ antes = self.paths()
+ code, out, _ = self.run_cli("--delete-empty", "--dry-run")
+ self.assertEqual(code, 0)
+ self.assertIn("no se tocó nada", out)
+ self.assertEqual(self.paths(), antes)
+
+ def test_borra_las_vacias(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("--delete-empty", "-y")
+ self.assertEqual(code, 0)
+ self.assertIn("1 sesión borrada", out)
+ self.assertEqual(self.paths(),
+ ["aaaaaaaa-0000-0000-0000-000000000001.jsonl"])
+
+ def test_borra_una_puntual_por_prefijo(self):
+ simple_tree(self.root)
+ code, _, _ = self.run_cli("-D", "aaaaaaaa", "-y")
+ self.assertEqual(code, 0)
+ self.assertEqual(self.paths(),
+ ["bbbbbbbb-0000-0000-0000-000000000002.jsonl"])
+
+ def test_no_repite_si_la_pediste_dos_veces(self):
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("-D", "aaaaaaaa", "1", "-y")
+ self.assertEqual(code, 0)
+ self.assertIn("1 sesión borrada", out)
+
+ def test_la_borrada_no_vuelve_desde_el_cache(self):
+ simple_tree(self.root)
+ self.run_cli() # llena el caché
+ self.run_cli("--delete-empty", "-y")
+ _, out, _ = self.run_cli()
+ self.assertIn("2 sesiones", out)
+ self.assertNotIn("bbbbbbbb", out)
+
+ def test_el_filtro_acota_lo_que_se_borra(self):
+ write_session(self.root, "-home-u-otro", "ffffffff-0000-0000-0000-000000000006",
+ [{"type": "system", "timestamp": ts(0)}])
+ simple_tree(self.root)
+ code, out, _ = self.run_cli("-p", "/home/u/proj", "--delete-empty", "-y")
+ self.assertEqual(code, 0)
+ self.assertIn("1 sesión borrada", out)
+ self.assertTrue(os.path.exists(os.path.join(
+ self.root, "-home-u-otro", "ffffffff-0000-0000-0000-000000000006.jsonl")))
+
+ def test_sin_nada_para_borrar_avisa(self):
+ write_session(self.root, "-p", "aaaaaaaa-0000-0000-0000-000000000001",
+ [user("hola", at=ts(0))])
+ code, _, err = self.run_cli("--delete-empty", "-y")
+ self.assertEqual(code, 0)
+ self.assertIn("No hay sesiones que borrar", err)
+
+
+class TestParser(unittest.TestCase):
+ def test_html_sin_valor_usa_el_nombre_por_defecto(self):
+ args = cli.build_parser().parse_args(["--html"])
+ self.assertEqual(args.html, cli.DEFAULT_HTML)
+
+ def test_html_con_valor(self):
+ self.assertEqual(cli.build_parser().parse_args(["--html", "x.html"]).html,
+ "x.html")
+
+ def test_la_query_junta_las_palabras(self):
+ args = cli.build_parser().parse_args(["dos", "palabras"])
+ self.assertEqual(args.query, ["dos", "palabras"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_sessions.py b/tests/test_sessions.py
new file mode 100644
index 0000000..9522639
--- /dev/null
+++ b/tests/test_sessions.py
@@ -0,0 +1,307 @@
+import json
+import os
+import tempfile
+import unittest
+import unittest.mock
+
+from claude_sesiones import sessions as S
+
+from .fixtures import ai_title, assistant, simple_tree, ts, user, write_session
+
+
+class TempRoot(unittest.TestCase):
+ def setUp(self):
+ self._tmp = tempfile.TemporaryDirectory()
+ self.root = os.path.join(self._tmp.name, "projects")
+ os.makedirs(self.root)
+ self.cache = os.path.join(self._tmp.name, "cache.json")
+ self.addCleanup(self._tmp.cleanup)
+
+ def load(self, **kw):
+ kw.setdefault("cache_path", self.cache)
+ return S.load_sessions(root=self.root, **kw)
+
+
+class TestReadSession(TempRoot):
+ def test_conversacion_basica(self):
+ path = write_session(self.root, "-p", "11111111-1111-1111-1111-111111111111", [
+ user("¿por qué falla?", at=ts(0)),
+ assistant("Miro.", tools=[("Bash", {"command": "make test"})], at=ts(5)),
+ ])
+ rec = S.read_session(path)
+
+ self.assertEqual(rec["u"], 1)
+ self.assertEqual(rec["a"], 1)
+ self.assertEqual(rec["d"], 5)
+ self.assertEqual(rec["p"], "/home/u/proj")
+ self.assertEqual(rec["b"], "main")
+ self.assertFalse(rec["e"])
+ self.assertFalse(rec["ai"])
+ self.assertEqual(rec["t"], "¿por qué falla?")
+ self.assertEqual([m["r"] for m in rec["c"]], ["u", "a", "t"])
+ self.assertEqual(rec["c"][2]["x"], "Bash: make test")
+
+ def test_el_titulo_de_claude_le_gana_al_primer_mensaje(self):
+ path = write_session(self.root, "-p", "22222222-0000-0000-0000-000000000000", [
+ user("arreglá esto", at=ts(0)),
+ ai_title("Primer intento"),
+ ai_title("Título final"),
+ ])
+ rec = S.read_session(path)
+ self.assertEqual(rec["t"], "Título final")
+ self.assertTrue(rec["ai"])
+
+ def test_ignora_el_ruido_del_harness(self):
+ path = write_session(self.root, "-p", "33333333-0000-0000-0000-000000000000", [
+ user("<command-name>/clear</command-name>", at=ts(0)),
+ user("<system-reminder>ojo</system-reminder>", at=ts(1)),
+ user("texto real <system-reminder>ojo</system-reminder>", at=ts(2)),
+ user("meta", at=ts(2), isMeta=True),
+ user("de un subagente", at=ts(3), isSidechain=True),
+ assistant("respuesta de subagente", at=ts(4), isSidechain=True),
+ ])
+ rec = S.read_session(path)
+ self.assertEqual(rec["u"], 1)
+ self.assertEqual(rec["a"], 0)
+ self.assertEqual(rec["c"][0]["x"], "texto real")
+
+ def test_tolera_una_linea_cortada_a_la_mitad(self):
+ path = write_session(self.root, "-p", "44444444-0000-0000-0000-000000000000", [
+ user("primero", at=ts(0)),
+ ])
+ with open(path, "a", encoding="utf-8") as f:
+ f.write('{"type": "user", "message": {"content": [{"type": "te')
+ rec = S.read_session(path)
+ self.assertEqual(rec["u"], 1)
+
+ def test_sesion_sin_mensajes(self):
+ path = write_session(self.root, "-p", "55555555-0000-0000-0000-000000000000", [
+ {"type": "system", "timestamp": ts(0), "cwd": "/home/u/proj"},
+ ])
+ rec = S.read_session(path)
+ self.assertTrue(rec["e"])
+ self.assertIsNone(rec["t"])
+ self.assertEqual(rec["c"], [])
+
+ def test_un_solo_mensaje_enorme_es_claude_p(self):
+ largo = "x" * (S.NONINTERACTIVE_CHARS + 1)
+ path = write_session(self.root, "-p", "66666666-0000-0000-0000-000000000000", [
+ user(largo, at=ts(0)),
+ ])
+ self.assertTrue(S.read_session(path)["n"])
+
+ def test_una_charla_corta_no_es_claude_p(self):
+ path = write_session(self.root, "-p", "77777777-0000-0000-0000-000000000000", [
+ user("hola", at=ts(0)),
+ ])
+ self.assertFalse(S.read_session(path)["n"])
+
+
+class TestToolSummary(unittest.TestCase):
+ def test_usa_el_parametro_representativo(self):
+ self.assertEqual(
+ S.tool_summary({"name": "Read", "input": {"file_path": "/a/b.py", "limit": 5}}),
+ "Read: /a/b.py")
+
+ def test_cae_al_primer_string_si_la_tool_es_desconocida(self):
+ self.assertEqual(
+ S.tool_summary({"name": "Rara", "input": {"n": 1, "q": "algo"}}),
+ "Rara: algo")
+
+ def test_recorta_los_argumentos_largos(self):
+ out = S.tool_summary({"name": "Bash", "input": {"command": "a" * 500}})
+ self.assertTrue(out.endswith("…"))
+ self.assertEqual(len(out), len("Bash: ") + S.TOOL_ARG_MAX + 1)
+
+ def test_sin_argumentos_usables(self):
+ self.assertEqual(S.tool_summary({"name": "X", "input": {"n": 1}}), "X")
+ self.assertEqual(S.tool_summary({"name": "X", "input": "no es dict"}), "X")
+
+
+class TestLoad(TempRoot):
+ def test_ordena_por_ultima_actividad(self):
+ simple_tree(self.root)
+ got = [s["id"][:8] for s in self.load()]
+ self.assertEqual(got, ["aaaaaaaa", "bbbbbbbb", "cccccccc"])
+
+ def test_deduce_la_ruta_de_otra_sesion_del_proyecto(self):
+ write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001",
+ [user("con cwd", at=ts(0))])
+ write_session(self.root, "-home-u-proj", "dddddddd-0000-0000-0000-000000000004",
+ [{"type": "system", "timestamp": ts(30)}])
+ huerfana = next(s for s in self.load() if s["id"].startswith("dddddddd"))
+ self.assertEqual(huerfana["p"], "/home/u/proj")
+ self.assertTrue(huerfana["i"])
+
+ def test_sin_ninguna_ruta_conocida_queda_el_nombre_del_directorio(self):
+ write_session(self.root, "-sin-cwd", "eeeeeeee-0000-0000-0000-000000000005",
+ [{"type": "system", "timestamp": ts(0)}])
+ s = self.load()[0]
+ self.assertEqual(s["p"], "-sin-cwd")
+ self.assertTrue(s["i"])
+
+ def test_ignora_los_jsonl_de_subagentes(self):
+ simple_tree(self.root)
+ sub = os.path.join(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001", "subagents")
+ os.makedirs(sub)
+ with open(os.path.join(sub, "agent-1.jsonl"), "w", encoding="utf-8") as f:
+ f.write(json.dumps(user("soy un subagente")) + "\n")
+ self.assertEqual(len(self.load()), 3)
+
+
+class TestCache(TempRoot):
+ def test_reusa_lo_que_no_cambio(self):
+ simple_tree(self.root)
+ self.load()
+
+ # Ensuciamos el caché a mano: si la segunda corrida devuelve el título
+ # falso es porque no volvió a leer el archivo.
+ with open(self.cache, encoding="utf-8") as f:
+ blob = json.load(f)
+ for entry in blob["entries"].values():
+ entry["rec"]["t"] = "vino del caché"
+ with open(self.cache, "w", encoding="utf-8") as f:
+ json.dump(blob, f)
+
+ self.assertEqual(self.load()[0]["t"], "vino del caché")
+
+ def test_reparsea_si_el_archivo_cambio(self):
+ path = write_session(self.root, "-p", "99999999-0000-0000-0000-000000000009",
+ [user("original", at=ts(0))])
+ self.load()
+ write_session(self.root, "-p", "99999999-0000-0000-0000-000000000009",
+ [user("cambiado", at=ts(0)), user("y otro", at=ts(1))])
+ self.assertEqual(self.load()[0]["u"], 2)
+ self.assertTrue(os.path.exists(path))
+
+ def test_un_cache_de_otra_version_se_descarta(self):
+ simple_tree(self.root)
+ self.load()
+ with open(self.cache, encoding="utf-8") as f:
+ blob = json.load(f)
+ blob["v"] = S.CACHE_VERSION - 1
+ for entry in blob["entries"].values():
+ entry["rec"]["t"] = "no debería verse"
+ with open(self.cache, "w", encoding="utf-8") as f:
+ json.dump(blob, f)
+
+ self.assertEqual(self.load()[0]["t"], "Arreglar el build")
+
+ def test_un_cache_roto_no_rompe_nada(self):
+ simple_tree(self.root)
+ with open(self.cache, "w", encoding="utf-8") as f:
+ f.write("{esto no es json")
+ self.assertEqual(len(self.load()), 3)
+
+ def test_no_cache_no_escribe_nada(self):
+ simple_tree(self.root)
+ self.load(use_cache=False)
+ self.assertFalse(os.path.exists(self.cache))
+
+ def test_el_cache_guarda_la_ruta_sin_deducir(self):
+ write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-000000000001",
+ [user("con cwd", at=ts(0))])
+ write_session(self.root, "-home-u-proj", "dddddddd-0000-0000-0000-000000000004",
+ [{"type": "system", "timestamp": ts(30)}])
+ self.load()
+ with open(self.cache, encoding="utf-8") as f:
+ blob = json.load(f)
+ recs = {os.path.basename(k): v["rec"] for k, v in blob["entries"].items()}
+ self.assertIsNone(recs["dddddddd-0000-0000-0000-000000000004.jsonl"]["p"])
+
+ def test_drop_from_cache_saca_las_borradas(self):
+ simple_tree(self.root)
+ self.load()
+ with open(self.cache, encoding="utf-8") as f:
+ paths = list(json.load(f)["entries"])
+ S.drop_from_cache(paths[:1], cache_path=self.cache)
+ with open(self.cache, encoding="utf-8") as f:
+ self.assertEqual(len(json.load(f)["entries"]), len(paths) - 1)
+
+
+class TestFiltros(TempRoot):
+ def setUp(self):
+ super().setUp()
+ simple_tree(self.root)
+ self.sessions = self.load()
+
+ def test_por_proyecto(self):
+ out = S.apply_filters(self.sessions, project="/home/u/otro")
+ self.assertEqual(len(out), 1)
+
+ def test_por_contenido_de_la_conversacion(self):
+ out = S.apply_filters(self.sessions, grep="build")
+ self.assertEqual([s["id"][:8] for s in out], ["aaaaaaaa"])
+
+ def test_por_titulo_ruta_rama_o_uuid(self):
+ self.assertEqual(len(S.apply_filters(self.sessions, query="arreglar")), 1)
+ self.assertEqual(len(S.apply_filters(self.sessions, query="main")), 2)
+ self.assertEqual(len(S.apply_filters(self.sessions, query="cccccccc")), 1)
+
+ def test_ocultar_vacias(self):
+ out = S.apply_filters(self.sessions, hide_empty=True)
+ self.assertTrue(all(not s["e"] for s in out))
+ self.assertEqual(len(out), 2)
+
+
+class TestPick(TempRoot):
+ def setUp(self):
+ super().setUp()
+ simple_tree(self.root)
+ self.sessions = self.load()
+
+ def test_por_indice(self):
+ self.assertEqual(S.pick(self.sessions, "1")["id"][:8], "aaaaaaaa")
+
+ def test_por_prefijo_de_uuid(self):
+ self.assertEqual(S.pick(self.sessions, "cccc")["id"][:8], "cccccccc")
+
+ def test_indice_fuera_de_rango(self):
+ with self.assertRaises(S.SessionError):
+ S.pick(self.sessions, "99")
+
+ def test_prefijo_inexistente(self):
+ with self.assertRaises(S.SessionError):
+ S.pick(self.sessions, "zzzz")
+
+ def test_prefijo_ambiguo(self):
+ write_session(self.root, "-home-u-proj", "aaaaaaaa-0000-0000-0000-0000000000ff",
+ [user("otra más", at=ts(50))])
+ with self.assertRaises(S.SessionError):
+ S.pick(self.load(), "aaaa")
+
+
+class TestPublicRecords(TempRoot):
+ def test_saca_las_claves_internas_sin_tocar_el_original(self):
+ simple_tree(self.root)
+ sessions = self.load()
+ pub = S.public_records(sessions)
+ for r in pub:
+ self.assertNotIn("project_dir", r)
+ self.assertNotIn("mtime", r)
+ self.assertIn("project_dir", sessions[0])
+
+
+class TestParseTs(unittest.TestCase):
+ def test_acepta_z_y_offset(self):
+ self.assertIsNotNone(S.parse_ts("2025-08-14T10:00:00.000Z"))
+ self.assertIsNotNone(S.parse_ts("2025-08-14T10:00:00+02:00"))
+
+ def test_devuelve_none_si_no_se_puede_leer(self):
+ self.assertIsNone(S.parse_ts(None))
+ self.assertIsNone(S.parse_ts(""))
+ self.assertIsNone(S.parse_ts("ayer a la tarde"))
+
+
+class TestRoots(unittest.TestCase):
+ def test_claude_config_dir_manda(self):
+ with unittest.mock.patch.dict(os.environ, {"CLAUDE_CONFIG_DIR": "/x/cfg"}):
+ self.assertEqual(S.default_root(), os.path.join("/x/cfg", "projects"))
+
+ def test_sin_variable_cae_al_home(self):
+ with unittest.mock.patch.dict(os.environ, {}, clear=True):
+ self.assertTrue(S.default_root().endswith(os.path.join(".claude", "projects")))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_terminal.py b/tests/test_terminal.py
new file mode 100644
index 0000000..fdb9fc8
--- /dev/null
+++ b/tests/test_terminal.py
@@ -0,0 +1,142 @@
+import io
+import unittest
+from datetime import datetime, timedelta, timezone
+
+from claude_sesiones import terminal as T
+
+NOW = datetime(2025, 8, 14, 12, 0, tzinfo=timezone.utc)
+
+
+def ago(days):
+ """Un ISO que queda a `days` días de NOW."""
+ return (NOW - timedelta(days=days)).isoformat()
+
+
+class TestStyle(unittest.TestCase):
+ def test_apagado_no_emite_nada(self):
+ st = T.Style(False)
+ self.assertEqual(st.amber, "")
+ self.assertEqual(st.reset, "")
+
+ def test_prendido_emite_ansi(self):
+ st = T.Style(True)
+ self.assertTrue(st.amber.startswith("\x1b["))
+
+ def test_un_color_que_no_existe_es_attribute_error(self):
+ with self.assertRaises(AttributeError):
+ T.Style(True).fucsia
+
+ def test_from_stream_sin_tty_apaga_el_color(self):
+ self.assertFalse(T.Style.from_stream(io.StringIO()).on)
+
+
+class TestFormato(unittest.TestCase):
+ def test_fmt_dur(self):
+ self.assertEqual(T.fmt_dur(None), "—")
+ self.assertEqual(T.fmt_dur(0), "<1m")
+ self.assertEqual(T.fmt_dur(45), "45m")
+ self.assertEqual(T.fmt_dur(60), "1h")
+ self.assertEqual(T.fmt_dur(125), "2h05")
+
+ def test_fmt_rel(self):
+ self.assertEqual(T.fmt_rel(ago(0.2), NOW), "hoy")
+ self.assertEqual(T.fmt_rel(ago(1.5), NOW), "ayer")
+ self.assertEqual(T.fmt_rel(ago(3), NOW), "hace 3d")
+ self.assertEqual(T.fmt_rel(ago(10), NOW), "hace 1sem")
+ self.assertEqual(T.fmt_rel(ago(70), NOW), "hace 2mes")
+
+ def test_fmt_size(self):
+ self.assertEqual(T.fmt_size(12.5), "12.5 KB")
+ self.assertEqual(T.fmt_size(2048), "2.0 MB")
+
+ def test_clip(self):
+ self.assertEqual(T.clip("hola", 10), "hola")
+ self.assertEqual(T.clip("hola mundo", 6), "hola …")
+ self.assertEqual(T.clip("con\nsalto", 20), "con salto")
+
+ def test_visible_len_ignora_los_codigos_ansi(self):
+ self.assertEqual(T.visible_len("\x1b[1mhola\x1b[0m"), 4)
+
+ def test_plural(self):
+ self.assertEqual(T.plural(1, "sesión", "sesiones"), "1 sesión")
+ self.assertEqual(T.plural(2, "sesión", "sesiones"), "2 sesiones")
+
+ def test_stripe_sin_color_es_una_barra(self):
+ self.assertEqual(T.stripe(ago(1), NOW, T.Style(False)), "|")
+
+ def test_stripe_cambia_de_color_con_la_edad(self):
+ st = T.Style(True)
+ colores = {T.stripe(ago(d), NOW, st) for d in (1, 4, 10, 60)}
+ self.assertEqual(len(colores), 4)
+
+
+class TestMarkdown(unittest.TestCase):
+ def test_strip_md(self):
+ self.assertEqual(T.strip_md("## Título"), "Título")
+ self.assertEqual(T.strip_md("esto es **fuerte**"), "esto es fuerte")
+
+ def test_render_block_respeta_las_cercas(self):
+ st = T.Style(False)
+ out = T.render_block("texto\n```py\nx = 1\n```\nfin", st, 40, "")
+ self.assertIn(" texto", out)
+ self.assertIn(" x = 1", out)
+ self.assertNotIn("```py", "".join(out))
+
+ def test_render_block_no_parte_palabras_largas(self):
+ out = T.render_block("a" * 60, T.Style(False), 20, "")
+ self.assertEqual(out, [" " + "a" * 60])
+
+
+class TestSalida(unittest.TestCase):
+ def sesion(self, **kw):
+ s = {
+ "id": "abcdef01-2345-6789-abcd-ef0123456789",
+ "p": "/home/u/proj", "b": "main", "t": "Arreglar el build",
+ "ai": True, "n": False, "e": False, "i": False,
+ "f": ago(1), "l": ago(1), "d": 12, "u": 2, "a": 3, "k": 10.0,
+ "v": "1.0.0", "c": [{"r": "u", "x": "hola"}, {"r": "a", "x": "chau"},
+ {"r": "t", "x": "Bash: ls"}],
+ }
+ s.update(kw)
+ return s
+
+ def test_print_table_sin_color_no_tiene_ansi(self):
+ buf = io.StringIO()
+ T.print_table([self.sesion()], T.Style(False), NOW, buf, width=120)
+ out = buf.getvalue()
+ self.assertNotIn("\x1b", out)
+ self.assertEqual(len(out.strip().split("\n")), 2) # encabezado + 1 fila
+ self.assertIn("Arreglar el build", out)
+ self.assertIn("abcdef01", out)
+
+ def test_print_table_angosta_esconde_columnas(self):
+ buf = io.StringIO()
+ T.print_table([self.sesion()], T.Style(False), NOW, buf, width=60)
+ self.assertNotIn("/home/u/proj", buf.getvalue())
+
+ def test_print_chat_incluye_el_comando_para_reanudar(self):
+ buf = io.StringIO()
+ T.print_chat(self.sesion(), T.Style(False), buf)
+ out = buf.getvalue()
+ self.assertIn("cd /home/u/proj && claude --resume abcdef01", out)
+ self.assertIn("hola", out)
+ self.assertIn("Bash: ls", out)
+
+ def test_no_tools_saca_las_herramientas(self):
+ buf = io.StringIO()
+ T.print_chat(self.sesion(), T.Style(False), buf, show_tools=False)
+ self.assertNotIn("Bash: ls", buf.getvalue())
+
+ def test_una_sesion_vacia_lo_dice(self):
+ buf = io.StringIO()
+ T.print_chat(self.sesion(c=[], t=None), T.Style(False), buf)
+ self.assertIn("no tiene mensajes", buf.getvalue())
+
+ def test_resume_cmd(self):
+ self.assertEqual(
+ T.resume_cmd({"p": "/a b", "id": "xyz"}),
+ "cd /a b && claude --resume xyz")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_webpage.py b/tests/test_webpage.py
new file mode 100644
index 0000000..e738458
--- /dev/null
+++ b/tests/test_webpage.py
@@ -0,0 +1,92 @@
+import json
+import os
+import re
+import tempfile
+import unittest
+
+from claude_sesiones import webpage
+
+PAYLOAD_RE = re.compile(
+ r'<script id="payload" type="application/json">(.*?)</script>', re.S)
+
+REGISTRO = {"id": "abc", "p": "/proj", "u": 1, "c": [{"r": "u", "x": "hola"}]}
+
+
+class TestPayload(unittest.TestCase):
+ def test_escapa_el_cierre_de_etiqueta(self):
+ raw = webpage.encode_payload([{"x": "mirá este </script> de acá"}])
+ self.assertNotIn("</", raw)
+ self.assertEqual(json.loads(raw)[0]["x"], "mirá este </script> de acá")
+
+ def test_no_escapa_a_ascii(self):
+ self.assertIn("ñ", webpage.encode_payload([{"x": "año"}]))
+
+
+class TestRender(unittest.TestCase):
+ def test_reemplaza_el_marcador(self):
+ html = webpage.render([REGISTRO], template="<b>__DATA__</b>")
+ self.assertNotIn("__DATA__", html)
+ self.assertIn('"id":"abc"', html)
+
+ def test_falla_si_el_template_no_tiene_marcador(self):
+ with self.assertRaises(webpage.TemplateError):
+ webpage.render([REGISTRO], template="<b>sin marcador</b>")
+
+ def test_falla_si_el_marcador_esta_repetido(self):
+ with self.assertRaises(webpage.TemplateError):
+ webpage.render([REGISTRO], template="__DATA__ y __DATA__")
+
+ def test_una_transcripcion_con_html_no_corta_el_script(self):
+ # El caso que motiva el escape: una sesión donde se habló de este mismo
+ # generador tiene "</script>" y "__DATA__" adentro del texto.
+ peligrosa = dict(REGISTRO, c=[{"r": "u", "x": "poné </script><img> y __DATA__"}])
+ html = webpage.render([peligrosa])
+
+ bloques = PAYLOAD_RE.findall(html)
+ self.assertEqual(len(bloques), 1)
+ vuelta = json.loads(bloques[0])
+ self.assertEqual(vuelta[0]["c"][0]["x"], "poné </script><img> y __DATA__")
+
+
+class TestTemplate(unittest.TestCase):
+ def setUp(self):
+ self.html = webpage.template_text()
+
+ def test_el_template_del_paquete_tiene_un_solo_marcador(self):
+ self.assertEqual(self.html.count(webpage.MARKER), 1)
+
+ def test_es_un_documento_completo(self):
+ # Sin doctype ni charset, un file:// se abre en quirks mode y con la
+ # codificación del sistema: los acentos salen rotos.
+ self.assertTrue(self.html.lstrip().startswith("<!doctype html>"))
+ self.assertIn('<meta charset="utf-8">', self.html)
+ self.assertIn('name="viewport"', self.html)
+ self.assertTrue(self.html.rstrip().endswith("</html>"))
+
+ def test_no_pide_nada_por_red(self):
+ for atributo in ("src=\"http", "href=\"http", "@import"):
+ self.assertNotIn(atributo, self.html)
+
+ def test_se_puede_pasar_otro_template(self):
+ with tempfile.NamedTemporaryFile("w", suffix=".html", delete=False,
+ encoding="utf-8") as f:
+ f.write("propio __DATA__")
+ ruta = f.name
+ self.addCleanup(os.unlink, ruta)
+ self.assertTrue(webpage.template_text(ruta).startswith("propio"))
+
+
+class TestWrite(unittest.TestCase):
+ def test_escribe_y_resume(self):
+ with tempfile.TemporaryDirectory() as d:
+ out = os.path.join(d, "s.html")
+ stats = webpage.write(
+ [REGISTRO, dict(REGISTRO, id="def", p="/otro", u=2)], out)
+ self.assertEqual(stats, {"sesiones": 2, "proyectos": 2,
+ "mensajes": 3, "bloques": 2})
+ with open(out, encoding="utf-8") as f:
+ self.assertEqual(len(PAYLOAD_RE.findall(f.read())), 1)
+
+
+if __name__ == "__main__":
+ unittest.main()