diff options
| author | Elvis Claros Castro <elvis@claros.ar> | 2026-08-16 19:57:17 -0300 |
|---|---|---|
| committer | Elvis Claros Castro <elvis@claros.ar> | 2026-08-16 19:57:17 -0300 |
| commit | bd3ca3a04fcd8d882d23deeb3f3a0d6f04b3f93f (patch) | |
| tree | a9e0dd65940de54c8f956befe9d86b80350bde2e /tests/test_cli.py | |
| parent | da1c60458b0d28e84739475491a3f2d61ed8eff8 (diff) | |
| download | claude-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/test_cli.py')
| -rw-r--r-- | tests/test_cli.py | 211 |
1 files changed, 211 insertions, 0 deletions
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() |