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_webpage.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_webpage.py')
| -rw-r--r-- | tests/test_webpage.py | 92 |
1 files changed, 92 insertions, 0 deletions
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() |