From 8742cb25bebb0a03e671a7c24183b9c29a531a1a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 11:42:31 +0200 Subject: [PATCH] feat(browser): auto-commit con 60 cambios Co-Authored-By: Claude Opus 4.7 (1M context) --- .claude/CLAUDE.md | 6 +- .claude/rules/INDEX.md | 2 + .claude/rules/artefactos.md | 5 +- .claude/rules/flow_replay.md | 76 ++++ .claude/rules/ids_naming.md | 2 +- .claude/rules/reports.md | 78 ++++ .gitignore | 7 + bash/functions/infra/reboot_all_claudes.md | 64 +++ bash/functions/infra/reboot_all_claudes.sh | 356 +++++++++++++++ docs/README.md | 2 + docs/adr/0006-reports-folder.md | 53 +++ docs/adr/README.md | 1 + docs/capabilities/INDEX.md | 1 + docs/capabilities/flow-replay.md | 117 +++++ functions/browser/cdp_click_ref.go | 37 +- functions/browser/cdp_click_xy_human.go | 19 +- functions/browser/cdp_close.go | 15 + functions/browser/cdp_close.md | 21 +- functions/browser/cdp_close_test.go | 25 ++ functions/browser/cdp_conn.go | 6 + functions/browser/cdp_eval_in_frame.go | 157 +++++-- functions/browser/cdp_eval_in_frame.md | 13 +- functions/browser/cdp_eval_in_frame_test.go | 79 ++++ functions/browser/cdp_find_ref_by_text.go | 141 ++++++ functions/browser/cdp_find_ref_by_text.md | 58 +++ .../browser/cdp_find_ref_by_text_test.go | 70 +++ functions/browser/cdp_get_ax_outline.go | 409 ++++++++++++++++++ functions/browser/cdp_get_ax_outline.md | 64 +++ functions/browser/cdp_get_ax_outline_test.go | 279 ++++++++++++ functions/browser/cdp_get_html.md | 12 +- functions/browser/cdp_get_text_in_frame.go | 44 ++ functions/browser/cdp_get_text_in_frame.md | 73 ++++ .../browser/cdp_get_text_in_frame_test.go | 21 + functions/browser/cdp_handle_dialog.go | 109 ++++- functions/browser/cdp_handle_dialog.md | 43 +- functions/browser/cdp_handle_dialog_test.go | 55 +++ functions/browser/cdp_move_mouse_human.go | 72 ++- functions/browser/cdp_save_storage_state.go | 54 ++- functions/browser/cdp_save_storage_state.md | 14 +- .../browser/cdp_save_storage_state_test.go | 54 +++ functions/browser/cdp_screenshot.go | 101 ++++- functions/browser/cdp_screenshot.md | 28 +- functions/browser/cdp_screenshot_bytes.md | 57 +++ functions/browser/cdp_screenshot_test.go | 76 ++++ functions/browser/cdp_wait_idle.go | 121 ++++-- functions/browser/cdp_wait_idle.md | 24 +- functions/browser/cdp_wait_idle_test.go | 94 +++- python/functions/browser/cdp_click_xy.md | 92 ++++ python/functions/browser/cdp_click_xy.py | 166 +++++++ python/functions/browser/cdp_click_xy_test.py | 143 ++++++ python/functions/browser/cdp_eval.md | 65 +++ python/functions/browser/cdp_eval.py | 139 ++++++ python/functions/browser/cdp_eval_test.py | 146 +++++++ python/functions/browser/cdp_press_key.md | 80 ++++ python/functions/browser/cdp_press_key.py | 144 ++++++ .../functions/browser/cdp_press_key_test.py | 143 ++++++ python/functions/browser/cdp_type_chars.md | 87 ++++ python/functions/browser/cdp_type_chars.py | 123 ++++++ .../functions/browser/cdp_type_chars_test.py | 123 ++++++ .../cybersecurity/har_extract_calls.md | 80 ++++ .../cybersecurity/har_extract_calls.py | 168 +++++++ .../cybersecurity/har_extract_calls_test.py | 150 +++++++ .../cybersecurity/har_filter_flows.md | 77 ++++ .../cybersecurity/har_filter_flows.py | 148 +++++++ .../cybersecurity/har_filter_flows_test.py | 93 ++++ python/functions/infra/__init__.py | 2 + python/functions/infra/generate_app_icon.py | 9 +- .../functions/infra/http_replay_sequence.md | 87 ++++ .../functions/infra/http_replay_sequence.py | 252 +++++++++++ .../infra/http_replay_sequence_test.py | 120 +++++ reports/.gitkeep | 0 71 files changed, 5660 insertions(+), 192 deletions(-) create mode 100644 .claude/rules/flow_replay.md create mode 100644 .claude/rules/reports.md create mode 100644 bash/functions/infra/reboot_all_claudes.md create mode 100755 bash/functions/infra/reboot_all_claudes.sh create mode 100644 docs/adr/0006-reports-folder.md create mode 100644 docs/capabilities/flow-replay.md create mode 100644 functions/browser/cdp_close_test.go create mode 100644 functions/browser/cdp_eval_in_frame_test.go create mode 100644 functions/browser/cdp_find_ref_by_text.go create mode 100644 functions/browser/cdp_find_ref_by_text.md create mode 100644 functions/browser/cdp_find_ref_by_text_test.go create mode 100644 functions/browser/cdp_get_ax_outline.go create mode 100644 functions/browser/cdp_get_ax_outline.md create mode 100644 functions/browser/cdp_get_ax_outline_test.go create mode 100644 functions/browser/cdp_get_text_in_frame.go create mode 100644 functions/browser/cdp_get_text_in_frame.md create mode 100644 functions/browser/cdp_get_text_in_frame_test.go create mode 100644 functions/browser/cdp_handle_dialog_test.go create mode 100644 functions/browser/cdp_save_storage_state_test.go create mode 100644 functions/browser/cdp_screenshot_bytes.md create mode 100644 functions/browser/cdp_screenshot_test.go create mode 100644 python/functions/browser/cdp_click_xy.md create mode 100644 python/functions/browser/cdp_click_xy.py create mode 100644 python/functions/browser/cdp_click_xy_test.py create mode 100644 python/functions/browser/cdp_eval.md create mode 100644 python/functions/browser/cdp_eval.py create mode 100644 python/functions/browser/cdp_eval_test.py create mode 100644 python/functions/browser/cdp_press_key.md create mode 100644 python/functions/browser/cdp_press_key.py create mode 100644 python/functions/browser/cdp_press_key_test.py create mode 100644 python/functions/browser/cdp_type_chars.md create mode 100644 python/functions/browser/cdp_type_chars.py create mode 100644 python/functions/browser/cdp_type_chars_test.py create mode 100644 python/functions/cybersecurity/har_extract_calls.md create mode 100644 python/functions/cybersecurity/har_extract_calls.py create mode 100644 python/functions/cybersecurity/har_extract_calls_test.py create mode 100644 python/functions/cybersecurity/har_filter_flows.md create mode 100644 python/functions/cybersecurity/har_filter_flows.py create mode 100644 python/functions/cybersecurity/har_filter_flows_test.py create mode 100644 python/functions/infra/http_replay_sequence.md create mode 100644 python/functions/infra/http_replay_sequence.py create mode 100644 python/functions/infra/http_replay_sequence_test.py create mode 100644 reports/.gitkeep diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2cd13900..ccde91b6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -23,7 +23,9 @@ Cualquier decision tecnica que choque con estos objetivos esta mal priorizada. E **Sub-repos:** cada app, cada analysis y **cada project** es su propio repo Gitea en `dataforge/` con branch `master` (ver ADR 0002). `apps/*`, `analysis/*` y `projects/*` estan en el `.gitignore` del repo padre — el codigo de cada app vive en `apps//.git/`. Cada `projects//` es a su vez un sub-repo que versiona solo sus docs de nivel-project (`project.md`, `CONVENTIONS.md`, ...) con un `.gitignore` interno que excluye `apps/*/` y `analysis/*/` (sub-repos hijos). Ver `.claude/rules/projects.md`. Los slash commands `/full-git-push` y `/full-git-pull` orquestan push/pull/clone de fn_registry + todos los sub-repos + `fn sync`. `/full-git-push` auto-inicializa apps/analyses sin `.git` via `ensure_repo_synced_bash_infra`. Los `vaults/` y `subrepos/` NO entran en este flujo. **Gotcha worktrees**: si creas una app nueva dentro de un git worktree del repo padre, haz `git init` dentro de `apps//` ANTES de limpiar el worktree, sino el codigo se pierde (apps/* gitignored). **REGLA DURA**: el repo padre NUNCA trackea contenido de artefactos hijos (apps/analysis/projects) — solo `.gitkeep`. Nada de `git add -f` sobre esos paths: deja el padre permanentemente dirty (doble-tracking). Auditoria + fix en `.claude/rules/apps_subrepo.md`. Ver `.claude/rules/apps_subrepo.md`. -**Artefactos:** termino paraguas para apps, analysis, vaults, projects y playgrounds — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md` y `.claude/rules/playgrounds.md`. +**Artefactos:** termino paraguas para apps, analysis, vaults, projects, playgrounds y reports — todo lo que NO es codigo reutilizable. Usa "artefacto" cuando una afirmacion aplica a varios tipos a la vez para no repetir la lista. Ver `.claude/rules/artefactos.md`, `.claude/rules/playgrounds.md` y `.claude/rules/reports.md`. + +**Reports:** reportes de trabajo (entregable de una tarea: resumen + cambios + verificacion con evidencia + gaps). Son **artefacto local**: viven en `reports/` o `projects/

/reports/`, estan gitignored (salvo `reports/.gitkeep`), NO suben a Gitea ni se versionan en el padre y NO se indexan — igual que los vaults/playgrounds. Compartir = pasar la ruta del `.md`. Convencion + plantilla en `.claude/rules/reports.md`. Decision: ADR 0006. **Reglas y convenciones:** ver `.claude/rules/INDEX.md` @@ -231,6 +233,8 @@ fn-registry/ docs/ # Specs de diseño docs/templates/ # Plantillas de frontmatter temp/ # Workspace efimero — pruebas, APIs, prototipos (gitignored, no indexado) + reports/ # Reportes de trabajo (artefacto local: gitignored salvo .gitkeep, no Gitea, no indexado) + projects/*/reports/ # Reportes de un proyecto concreto (mismo trato: gitignored, local) /playground/ # Prototipo rapido dentro de un artefacto padre (analysis/app/proyecto). No se indexa ``` diff --git a/.claude/rules/INDEX.md b/.claude/rules/INDEX.md index 18b83247..a178fd58 100644 --- a/.claude/rules/INDEX.md +++ b/.claude/rules/INDEX.md @@ -40,3 +40,5 @@ Reglas operativas del proyecto. Cada archivo es una regla independiente. | 33 | [project_commands.md](project_commands.md) | Slash commands por project (`.claude/commands//`) expuestos via symlink. Desde fn_registry: `/:foo`. Desde el project: `/foo`. Sin colision. | | 34 | [dod_quality.md](dod_quality.md) | DoD Quality Triada: Mecanica + Cobertura (golden + edge + error path con evidencia ejecutable) + Vida util validada (>=7 dias uso real). Cierra anti-criterios contra checkbox vago. Aplica a `dev/flows/` y issues user-facing. | | 35 | [llm_invocation.md](llm_invocation.md) | Invocacion de LLM: SIEMPRE `ask_llm` (grupo `claude-direct`, API directa, arranque 0), NUNCA `claude -p` (lento, cold start). One-shot/streaming/tool-loop + legacy `claude_stream_go_core` deprecado. | +| 36 | [reports.md](reports.md) | Reports: reportes de trabajo como artefacto local (entregable de tarea con evidencia). Gitignored salvo `.gitkeep`, NO suben a Gitea ni se indexan (como vaults+playgrounds). Viven en `reports/` o `projects/

/reports/`. Convencion + plantilla. ADR 0006. | +| 37 | [flow_replay.md](flow_replay.md) | Flow replay: guardar un flujo web (login, reiniciar server, formulario) como funcion del registry. Patron grabar→destilar→reproducir con jerarquia HTTP puro > headless chromium > visible humanizado. Empieza por Nivel 1. Seguridad: HAR sensible, secrets a pass, acciones con efecto exigen confirmacion. Grupo `flow-replay`. Issue 0087. | diff --git a/.claude/rules/artefactos.md b/.claude/rules/artefactos.md index e9c2e7d5..90898b09 100644 --- a/.claude/rules/artefactos.md +++ b/.claude/rules/artefactos.md @@ -1,6 +1,6 @@ ## Artefactos: termino colectivo -**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds" cada vez. +**"Artefacto"** es el termino paraguas para todo lo que vive en el registry pero NO es codigo reutilizable de `functions/` o `types/`. Sirve para no repetir "apps, analysis, vaults, projects, playgrounds, reports" cada vez. Tipos de artefacto: @@ -11,6 +11,7 @@ Tipos de artefacto: | **vault** | `projects/

/vaults/` (symlink) | tabla `vaults` | no (datos fuera del repo) | | **project** | `projects/

/` | tabla `projects` | no (vive dentro de fn_registry) | | **playground** | `/playground/` | NO se indexa | no (vive dentro del padre) | +| **report** | `reports/`, `projects/

/reports/` | NO se indexa | no (local, gitignored, no sube a Gitea — como vaults) | Caracteristicas comunes de los artefactos: - NO son codigo reutilizable. La reutilizacion vive en `functions/`. @@ -18,6 +19,8 @@ Caracteristicas comunes de los artefactos: - `pc_locations` los unifica via `entity_type` (app, analysis, project, vault). - Pueden importar funciones del registry; el registry NUNCA importa de un artefacto. +**Reports** son el caso mas ligero: artefacto local (gitignored salvo `reports/.gitkeep`), NO sube a Gitea ni se versiona en el padre (como los vaults), NO se indexa (como los playgrounds). Convencion en [[reports]]. Pueden vivir sueltos en `reports/` o dentro de un proyecto en `projects/

/reports/`. + ### Cuando usar el termino Usa "artefacto" cuando hablas de varios tipos a la vez o cuando la afirmacion aplica a todos: diff --git a/.claude/rules/flow_replay.md b/.claude/rules/flow_replay.md new file mode 100644 index 00000000..4b43a6e3 --- /dev/null +++ b/.claude/rules/flow_replay.md @@ -0,0 +1,76 @@ +## Flow replay: guardar un flujo web como función reproducible + +Cuando una acción web se hace **más de una vez** (login en un panel, reiniciar un servidor +desde su consola, rellenar un formulario recurrente, descargar un export), deja de hacerse a +mano: se **graba una vez y se promueve a función del registry**. Es la doctrina del issue 0087 +aplicada a la navegación — el registry crece convirtiendo secuencias repetidas en operaciones +de un solo paso, no inflando funciones existentes. + +Grupo de capacidad: `flow-replay`. Página madre: `docs/capabilities/flow-replay.md`. Graba con +el grupo `web-proxy`; destila y reproduce con `flow-replay`. + +### El patrón: grabar → destilar → reproducir + +1. **Grabar** (una vez, con browser + proxy): `web_proxy` ON, haces la acción a mano, + exportas el tramo a HAR (`query_mitm_flows --har`). +2. **Destilar**: `har_filter_flows_py_cybersecurity` (quita ruido) → + `har_extract_calls_py_cybersecurity` (call specs reproducibles). +3. **Reproducir**, en esta jerarquía de preferencia (de barato a caro): + +| Nivel | Mecanismo | Cuándo | +|---|---|---| +| **1 — HTTP puro** | `http_replay_sequence_py_infra` | **Por defecto.** Rápido, headless, scriptable. La mayoría de paneles admin funcionan con cookie de sesión + requests. | +| **2 — headless chromium** | action recipe (reutiliza `cdp_extract_recipe` + `cdp_save_storage_state`) | Token dinámico firmado en cliente, challenge JS obligatorio, WAF con fingerprint. | +| **3 — chromium visible + humanizado** | `cdp_click_xy_human`, `cdp_move_mouse_human` | Headless detectado/bloqueado. Último recurso. | + +**Empieza SIEMPRE por el Nivel 1.** Solo baja de nivel cuando el anterior demuestre no +reproducir el efecto. Construir el runner de Nivel 2/3 por adelantado, sin un caso que lo +exija, es especular (KISS): se monta cuando un flujo real falle en HTTP puro. + +### Flujo de autoría (cómo guardar una función-acción nueva) + +1. Grabar el flujo y exportar el HAR del tramo. +2. `har_filter_flows` + `har_extract_calls` → boceto de la secuencia. El agente **lee** el + HAR (es texto) e identifica los 2-4 requests que producen el efecto (auth + acción + + confirmación), descartando el resto. +3. Parametrizar: marcar los valores variables (ids, tokens) como `{{param}}`; definir las + reglas `extract` para los tokens que una respuesta genera y otro request consume. +4. Validar el replay con `http_replay_sequence`. Si reproduce el efecto sin navegador → Nivel 1. +5. **Promover a función del registry**: delegar a `fn-constructor` una función-acción nombrada + con verbo (`reboot_vps_server_`, `login_`, `export__report`) que + internamente llama a `http_replay_sequence` con su secuencia fija, recibe los parámetros + del caller y resuelve los secretos desde `pass`/vault. Tag de grupo `flow-replay` + el + dominio que toque (infra, cybersecurity, …). `fn index` + usar en el mismo turno. + +### Reglas duras de seguridad + +- **El HAR es un secreto**: lleva cookies/tokens en crudo. Gitignored, no subir a Gitea, no + indexar, borrar tras destilar. El output de `har_extract_calls` también, hasta sustituir por + `{{param}}`. +- **Secretos a `pass`/vault**, jamás hardcodeados en la función-acción. +- **Replay con efectos = peligroso.** Una acción destructiva o irreversible (reiniciar, borrar, + pagar, enviar) NUNCA se reproduce a ciegas: la función-acción exige confirmación o un flag + explícito (`confirm=True` / `--yes`) antes de disparar. +- `http_replay_sequence` usa `verify_tls=True` y sigue redirects por defecto; la extracción + JSON es dot-path simple, no JSONPath completo. + +### Anti-patrones + +| Anti-patrón | Por qué es malo | Sustituir por | +|---|---|---| +| Repetir el flujo a mano cada vez | No capitaliza; lento; propenso a error | Grabar una vez → función-acción | +| Reescribir requests inline en un heredoc/app cada vez | Reinvento, sin telemetría | Función-acción que llama `http_replay_sequence` | +| Empezar por chromium headless "por si acaso" | Más caro y frágil que HTTP puro | Nivel 1 primero, bajar solo si falla | +| Hardcodear cookie/token del HAR en el código | Secreto filtrado + caduca | `{{param}}` desde `pass`/vault | +| Commitear el HAR o el output crudo de extract | Filtración de credenciales | Tratar como secreto, gitignored | +| Replay ciego de un POST destructivo | Daño irreversible | Confirmación / flag explícito | + +### Relación con otras reglas + +- [[registry_first]] — buscar/reutilizar antes de escribir; la función-acción se delega a + `fn-constructor`, no se escribe inline. +- [[function_growth_and_self_docs]] — el registry crece por promoción de composiciones + repetidas a funciones one-shot (issue 0087); esto es ese patrón para la navegación. +- [[registry_calls]] — invocar las funciones del grupo por los patrones canónicos (MCP / + `fn run` / heredoc que importa). +- Grupo `web-proxy` (`docs/capabilities/web-proxy.md`) — la captura que alimenta la Fase 0. diff --git a/.claude/rules/ids_naming.md b/.claude/rules/ids_naming.md index 85870871..1b79f5a8 100644 --- a/.claude/rules/ids_naming.md +++ b/.claude/rules/ids_naming.md @@ -13,7 +13,7 @@ IDs: `{name}_{lang}_{domain}` (ej: `filter_slice_go_core`). Predictibilidad alta Lista no exhaustiva pero cubre la mayoria. Anadir aqui (y al validator en `apps/registry_mcp/naming.go`) cuando se introduzca un verbo nuevo recurrente. -`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, start, stop, kill, restart, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate` +`get, set, list, find, search, show, read, load, fetch, scan, query, lookup, parse, format, encode, decode, marshal, unmarshal, serialize, deserialize, validate, check, ensure, verify, audit, diagnose, test, match, filter, map, reduce, sort, group, count, sum, aggregate, compute, calculate, score, rank, cluster, classify, detect, init, create, make, build, generate, scaffold, install, setup, configure, register, add, insert, append, prepend, update, upsert, modify, edit, patch, replace, delete, remove, clear, drop, prune, clean, copy, move, rename, sync, clone, extract, inject, import, export, send, post, put, call, dispatch, exec, run, launch, relaunch, start, stop, kill, restart, reboot, redeploy, deploy, open, close, connect, disconnect, login, logout, authenticate, enable, disable, toggle, lock, unlock, propose, promote, deprecate, approve, reject, emit, render, draw, paint, serve, host, pull, push, checkout, commit, tag, merge, rebase, watch, monitor, observe, log, trace, profile, benchmark, snapshot, backup, restore, archive, compress, decompress, hash, encrypt, decrypt, sign, taskkill, recopile, vault, propose, apply, gather, collect, fold, head, tail, take, drop, slice, chunk, batch, debounce, throttle, retry, await, sleep, ping, kill, prime, warm, refresh, invalidate, reload, reset, rollback, fork, spawn, daemon, observe, plot, draw, capture, replay, recopilate` ### Excepciones diff --git a/.claude/rules/reports.md b/.claude/rules/reports.md new file mode 100644 index 00000000..2ee36d8a --- /dev/null +++ b/.claude/rules/reports.md @@ -0,0 +1,78 @@ +## Reports: reportes de trabajo como artefacto local + +Un **report** es el entregable escrito de una tarea no trivial: qué se hizo, cómo se verificó y qué quedó pendiente, en formato copiable de un vistazo. Sirve para conservar el resultado fuera del chat y compartirlo rápido pasando la ruta del archivo. + +Un report es un **artefacto** (ver `artefactos.md`), no documentación del registry. En consecuencia: + +- **NO se versiona en el git del padre `fn_registry`** ni en ningún sub-repo: `reports/*` está en el `.gitignore` (solo el marcador `reports/.gitkeep` se versiona). Igual que los **vaults**. +- **NO sube a Gitea**: un report no tiene repo propio. Vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. +- **NO se indexa en `registry.db`**: no hay tabla `reports` ni schema. KISS — son texto plano efímero, como los `playgrounds`. + +### Qué NO es un report + +| Es | Va a | +|---|---| +| Decisión de diseño (qué se decidió y por qué) | `docs/adr/` (versionado) | +| Norma operativa / convención | `.claude/rules/` (versionado) | +| Bitácora cronológica libre | `docs/diary/` (versionado) | +| **Resultado de una tarea concreta + su evidencia** | **`reports/` (artefacto local, NO versionado)** | + +Si durante el trabajo aparece una decisión de diseño, esa decisión va a `docs/adr/` y el report solo la referencia. + +### Ubicación + +Como cualquier artefacto, un report puede vivir en dos sitios: + +| Ubicación | Para qué | +|---|---| +| `reports/` (raíz) | Reportes que no pertenecen a ningún proyecto | +| `projects/

/reports/` | Reportes del trabajo de un proyecto concreto | + +Ambas rutas están gitignored (`reports/*`, `projects/*/reports/`). Se pueden crear subcarpetas bajo `reports/` para agrupar (`reports/browser/`, `reports/audits/`, …). + +### Convención de nombre + +``` +NNNN-YYYY-MM-DD-slug-corto.md +``` + +- `NNNN` — número incremental de 4 dígitos por carpeta (0001, 0002, …). Referencia corta ("report 0003"). +- `YYYY-MM-DD` — fecha del trabajo (ISO en el nombre; en el cuerpo, fechas en formato europeo DD/MM/AAAA). +- `slug-corto` — kebab-case descriptivo. Ej: `browser-domain-audit-fixes`. + +### Plantilla mínima + +```markdown +# Report NNNN — Título + +- **Fecha:** DD/MM/AAAA +- **Autor:** (agente/humano) +- **Ámbito:** (dominio/app/módulo tocado) +- **Estado:** done | parcial | bloqueado + +## Resumen +Qué se hizo y el resultado, en 2-4 líneas. + +## Cambios +Tabla o lista de lo tocado/creado, con el porqué. + +## Verificación +Comandos ejecutados + salida cruda (build/test/vet/e2e). Sin "verde" sin evidencia. + +## Gaps / pendientes +Lo que NO se cubrió y por qué (honesto: requiere Chrome, scope, etc.). +``` + +### Reglas + +- **Cuándo escribir uno**: auditorías, tandas de fixes con verificación, refactors, investigaciones — cualquier trabajo cuyo resumen pedirías "para compartir rápido". Un fix de una línea NO necesita report; basta el commit. +- **Evidencia ejecutable obligatoria**: cada "pasa" lleva su comando/salida. Nada de smoke "no petó". Alineado con `dod_quality.md`. +- **Honestidad sobre gaps**: declarar siempre qué quedó sin cubrir. +- **Índice opcional**: si una carpeta de reports acumula muchos, mantener un `INDEX.md` local (también gitignored) ayuda a navegar; no es obligatorio. + +### Relación con otras reglas y ADRs + +- [[artefactos]] — report es un tipo de artefacto (no código reutilizable, ciclo de vida propio). +- [[playgrounds]] — mismo espíritu (artefacto local no indexado); el playground es prototipo de código, el report es resultado escrito. +- [[dod_quality]] — los reports heredan su exigencia de evidencia + gaps. +- ADR 0006 (`docs/adr/0006-reports-folder.md`) — decisión que crea la carpeta `reports/`. diff --git a/.gitignore b/.gitignore index d05c0482..86902d29 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,13 @@ projects/*/ vaults/*/ !vaults/vault.yaml +# Reports — artefacto local: reportes de trabajo. Como los vaults, NO suben a +# Gitea ni se versionan en el padre (solo el marcador .gitkeep). Conviven en +# reports/ (raíz) o projects/

/reports/. Convención: .claude/rules/reports.md +reports/* +!reports/.gitkeep +projects/*/reports/ + # Node / pnpm **/node_modules/ diff --git a/bash/functions/infra/reboot_all_claudes.md b/bash/functions/infra/reboot_all_claudes.md new file mode 100644 index 00000000..84cdb9ab --- /dev/null +++ b/bash/functions/infra/reboot_all_claudes.md @@ -0,0 +1,64 @@ +--- +name: reboot_all_claudes +kind: function +lang: bash +domain: infra +version: "1.0.0" +purity: impure +signature: "reboot_all_claudes([--go|--yes] [--resume-mode resume|continue|none] [--exclude-current] [--only-idle] [-h|--help])" +description: "Cierra todas las terminales kitty con una sesion de Claude Code corriendo y las relanza retomando la misma sesion (claude --resume ). Mapea cada PID vivo a su ~/.claude/sessions/.json para sacar sessionId, cwd y la ventana kitty. DRY-RUN por defecto; --go ejecuta de verdad de forma desacoplada." +tags: [claude, session, terminal, kitty, reboot, infra, terminal-capture] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +params: + - name: "--go" + desc: "Ejecuta de verdad: mata las ventanas kitty y relanza las sesiones (detached). Alias --yes. Sin esto es dry-run." + - name: "--yes" + desc: "Alias de --go." + - name: "--resume-mode " + desc: "Estrategia de reanudacion. resume (default): claude --resume . continue: claude --continue. none: sesion nueva en el mismo cwd." + - name: "--exclude-current" + desc: "No cierra ni relanza la terminal desde la que se invoca. Detecta el claude propio subiendo por la cadena de PPIDs hasta hallar un ancestro con comm=claude." + - name: "--only-idle" + desc: "Omite las sesiones con status busy (no pierde el turno en vuelo). Por defecto se incluyen todas y el dry-run avisa cuales estan busy." + - name: "-h|--help" + desc: "Muestra el uso y termina." +output: "Imprime una tabla del plan (PID, KITTY_PID, status, accion, sessionId, cwd) y el comando claude exacto por sesion. En dry-run no toca nada. Con --go lanza un script desacoplado en /tmp que cierra ventanas y relanza. Exit 0 normal; exit 2 si flags invalidos." +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/reboot_all_claudes.sh" +notes: "Mecanismo (Claude Code 2.1.x sobre Linux + kitty): pgrep -x claude -> PIDs vivos; ~/.claude/sessions/.json -> sessionId/cwd/status/procStart; anti-PID-reciclado comparando procStart del JSON con el campo 22 de /proc//stat; KITTY_PID del environ -> ventana a cerrar con SIGTERM; cmdline -> flags conservados (sin argv0 ni resume previos). El relanzamiento usa setsid kitty --directory zsh -ic 'claude ...; exec zsh'. Como la propia terminal es una victima, el plan --go se escribe a /tmp y se lanza con setsid para sobrevivir al cierre del padre." +--- + +## Ejemplo + +```bash +# Dry-run (default seguro): ver el plan sin tocar nada. +reboot_all_claudes + +# Reiniciar de verdad todas las sesiones MENOS la terminal actual. +reboot_all_claudes --go --exclude-current + +# Reiniciar solo las sesiones idle (no perder turnos en vuelo), de verdad. +reboot_all_claudes --go --only-idle + +# Arrancar sesiones nuevas (sin reanudar la conversacion) en cada cwd. +reboot_all_claudes --go --resume-mode none +``` + +## Cuando usarla + +Tras actualizar Claude Code (para que todas las sesiones corran la version nueva), o cuando varias sesiones se cuelgan y quieres reiniciarlas todas de golpe retomando exactamente la conversacion donde estaba cada una. Lanza siempre primero sin flags para revisar el plan; luego repite con `--go`. + +## Gotchas + +- **Es impura y se auto-mata.** La terminal desde la que la invocas suele ser una de las victimas; por eso el modo `--go` escribe un script a `/tmp/reboot_all_claudes...sh` y lo lanza con `setsid` para que el reparenting a init garantice los relanzamientos aunque el padre muera. Usa `--exclude-current` si quieres conservar la terminal actual. +- **Sesiones `busy` pierden el turno en vuelo.** Por defecto se reinician igual y el dry-run lo avisa explicitamente. Al reanudar con `--resume` se recupera hasta el ultimo mensaje completo guardado en el `.jsonl`. Usa `--only-idle` para no tocarlas. +- **Depende de `~/.claude/sessions/.json`** (formato de Claude Code 2.1.x). Si una version futura cambia el formato, la funcion deja de mapear PID -> sessionId y omitira las sesiones. +- **Asume kitty como terminal.** Si un claude corre fuera de kitty (sin `KITTY_PID` en el environ, p.ej. terminal integrado de un editor), el fallback mata directamente el PID de claude y abre una kitty nueva en su `cwd`. +- **Anti-PID-reciclado:** valida `procStart` del JSON contra el campo 22 de `/proc//stat`; si no coincide (o el JSON no existe, o `kill -0` falla) la sesion se omite como huerfana. diff --git a/bash/functions/infra/reboot_all_claudes.sh b/bash/functions/infra/reboot_all_claudes.sh new file mode 100755 index 00000000..a56eec7a --- /dev/null +++ b/bash/functions/infra/reboot_all_claudes.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# reboot_all_claudes — Cierra todas las terminales con una sesion de Claude Code +# corriendo y las relanza retomando exactamente la sesion que tenian +# (claude --resume ). Por defecto es DRY-RUN: imprime el plan sin +# tocar nada. Usar --go para ejecutarlo de verdad. +# +# Mecanismo (Claude Code 2.1.x sobre Linux + kitty): +# - pgrep -x claude -> PIDs de las sesiones interactivas vivas. +# - ~/.claude/sessions/.json -> mapea PID a {sessionId, cwd, status, procStart}. +# - Anti-PID-reciclado: procStart del JSON debe coincidir con el campo 22 de +# /proc//stat; ademas kill -0 debe tener exito. +# - KITTY_PID del environ del proceso -> ventana kitty a cerrar. +# - cmdline del proceso -> flags originales a conservar (sin argv0 ni resume previos). +# - Relanzamiento detached (setsid) para sobrevivir al cierre de la propia terminal. +set -euo pipefail +IFS=$' \t\n' + +reboot_all_claudes() { + local mode="dry" # dry | go + local resume_mode="resume" # resume | continue | none + local exclude_current=0 + local only_idle=0 + + # ----------------------------------------------------------------------- + # Parseo de argumentos + # ----------------------------------------------------------------------- + while [[ $# -gt 0 ]]; do + case "$1" in + --go|--yes) + mode="go" + ;; + --resume-mode) + shift + resume_mode="${1:-}" + case "$resume_mode" in + resume|continue|none) ;; + *) + echo "reboot_all_claudes: --resume-mode invalido: '$resume_mode' (usa resume|continue|none)" >&2 + return 2 + ;; + esac + ;; + --exclude-current) + exclude_current=1 + ;; + --only-idle) + only_idle=1 + ;; + -h|--help) + cat <<'USAGE' +Uso: reboot_all_claudes [opciones] + +Cierra todas las terminales con una sesion de Claude Code corriendo y las +relanza retomando la misma sesion (claude --resume ). + +Por defecto es DRY-RUN (accion destructiva => default seguro): imprime el plan +y NO mata ni relanza nada. + +Opciones: + --go, --yes Ejecuta de verdad (kills + relanzamientos detached). + --resume-mode resume (default) | continue | none. + resume -> claude --resume + continue -> claude --continue + none -> claude (sesion nueva en el mismo cwd) + --exclude-current No cierra ni relanza la terminal desde la que se invoca. + --only-idle Omite sesiones con status busy (no pierde turnos en vuelo). + -h, --help Muestra esta ayuda. + +Ejemplos: + reboot_all_claudes # dry-run, ve el plan + reboot_all_claudes --go --exclude-current # reinicia todas menos esta terminal +USAGE + return 0 + ;; + *) + echo "reboot_all_claudes: opcion desconocida: '$1' (usa -h)" >&2 + return 2 + ;; + esac + shift + done + + # ----------------------------------------------------------------------- + # Detectar el PID de la sesion actual subiendo por la cadena de ancestros + # hasta encontrar un proceso cuyo comm sea exactamente "claude". + # ----------------------------------------------------------------------- + local current_claude_pid="" + if [[ "$exclude_current" -eq 1 ]]; then + local walk="$$" + local guard=0 + while [[ -n "$walk" && "$walk" != "0" && "$walk" != "1" ]]; do + local comm="" + comm="$(cat "/proc/$walk/comm" 2>/dev/null || true)" + if [[ "$comm" == "claude" ]]; then + current_claude_pid="$walk" + break + fi + # campo 4 de /proc//stat es el PPID + walk="$(awk '{print $4}' "/proc/$walk/stat" 2>/dev/null || true)" + guard=$((guard + 1)) + [[ "$guard" -gt 64 ]] && break + done + fi + + # ----------------------------------------------------------------------- + # Recolectar las sesiones vivas y validarlas. + # ----------------------------------------------------------------------- + local sessions_dir="$HOME/.claude/sessions" + local pids="" + pids="$(pgrep -x claude 2>/dev/null || true)" + + if [[ -z "$pids" ]]; then + echo "reboot_all_claudes: no hay sesiones de Claude Code vivas (pgrep -x claude vacio)." + return 0 + fi + + # Arrays paralelos con el plan validado. + local -a plan_pid plan_kitty plan_status plan_cwd plan_sid plan_cmd plan_skip plan_skipreason + + local pid + for pid in $pids; do + # Validacion 1: el proceso debe seguir vivo. + if ! kill -0 "$pid" 2>/dev/null; then + continue + fi + + # Validacion 2: debe existir su JSON de sesion. + local json="$sessions_dir/$pid.json" + if [[ ! -f "$json" ]]; then + continue + fi + + # Parsear el JSON con python3 (campos sessionId, cwd, status, procStart). + # Salida: lineas "clave=valor" en orden fijo. + local parsed="" + parsed="$(python3 - "$json" <<'PY' 2>/dev/null || true +import json, sys +try: + with open(sys.argv[1]) as fh: + d = json.load(fh) +except Exception: + sys.exit(0) +print("sessionId=" + str(d.get("sessionId", ""))) +print("cwd=" + str(d.get("cwd", ""))) +print("status=" + str(d.get("status", ""))) +print("procStart=" + str(d.get("procStart", ""))) +PY +)" + [[ -z "$parsed" ]] && continue + + local sid cwd status proc_start_json + sid="$(printf '%s\n' "$parsed" | sed -n 's/^sessionId=//p')" + cwd="$(printf '%s\n' "$parsed" | sed -n 's/^cwd=//p')" + status="$(printf '%s\n' "$parsed" | sed -n 's/^status=//p')" + proc_start_json="$(printf '%s\n' "$parsed" | sed -n 's/^procStart=//p')" + + [[ -z "$sid" ]] && continue + + # Validacion 3 (anti-PID-reciclado): procStart del JSON debe coincidir + # con el campo 22 de /proc//stat. + local proc_start_real="" + proc_start_real="$(awk '{print $22}' "/proc/$pid/stat" 2>/dev/null || true)" + if [[ -n "$proc_start_json" && "$proc_start_json" != "$proc_start_real" ]]; then + # JSON huerfano de un PID reciclado: omitir. + continue + fi + + # KITTY_PID de la ventana kitty (vacio si claude no corre en kitty). + local kitty_pid="" + kitty_pid="$(tr '\0' '\n' < "/proc/$pid/environ" 2>/dev/null | sed -n 's/^KITTY_PID=//p' | head -n1)" + + # Flags originales: leer cmdline, descartar argv0 (claude) y cualquier + # flag de resume/continue previo para no duplicarlos. + local raw_cmd="" + raw_cmd="$(tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true)" + local -a kept_flags=() + local first=1 tok skipnext=0 + while IFS= read -r tok; do + [[ -z "$tok" ]] && continue + if [[ "$first" -eq 1 ]]; then + # argv0 (la ruta o nombre de claude) — descartar. + first=0 + continue + fi + if [[ "$skipnext" -eq 1 ]]; then + skipnext=0 + continue + fi + case "$tok" in + --resume|--continue|-r|-c) + # Resume/continue previos: omitir (y su posible valor para --resume). + if [[ "$tok" == "--resume" || "$tok" == "-r" ]]; then + skipnext=1 + fi + continue + ;; + esac + kept_flags+=("$tok") + done <<< "$raw_cmd" + + # Construir la estrategia de resume. + local -a launch_args=() + case "$resume_mode" in + resume) launch_args=("--resume" "$sid") ;; + continue) launch_args=("--continue") ;; + none) launch_args=() ;; + esac + launch_args+=("${kept_flags[@]}") + + # Comando claude final (para mostrar y ejecutar). + local claude_cmd="claude" + local a + for a in "${launch_args[@]}"; do + claude_cmd+=" $(printf '%q' "$a")" + done + + # Decidir si se omite esta sesion del plan. + local skip=0 skipreason="" + if [[ "$exclude_current" -eq 1 && -n "$current_claude_pid" && "$pid" == "$current_claude_pid" ]]; then + skip=1 + skipreason="terminal actual (--exclude-current)" + elif [[ "$only_idle" -eq 1 && "$status" == "busy" ]]; then + skip=1 + skipreason="busy (--only-idle)" + fi + + plan_pid+=("$pid") + plan_kitty+=("${kitty_pid:-}") + plan_status+=("${status:-?}") + plan_cwd+=("${cwd:-?}") + plan_sid+=("$sid") + plan_cmd+=("$claude_cmd") + plan_skip+=("$skip") + plan_skipreason+=("$skipreason") + done + + local total="${#plan_pid[@]}" + if [[ "$total" -eq 0 ]]; then + echo "reboot_all_claudes: ninguna sesion valida encontrada (todos los PIDs eran huerfanos o reciclados)." + return 0 + fi + + # ----------------------------------------------------------------------- + # Imprimir el plan (siempre, tanto en dry-run como en --go). + # ----------------------------------------------------------------------- + echo "reboot_all_claudes — modo: ${mode} resume: ${resume_mode} sesiones: ${total}" + echo + printf '%-8s %-9s %-7s %-6s %-38s %s\n' "PID" "KITTY" "STATUS" "ACCION" "SESSION_ID" "CWD" + printf '%-8s %-9s %-7s %-6s %-38s %s\n' "--------" "---------" "-------" "------" "--------------------------------------" "---" + + local i busy_count=0 act_count=0 + for ((i = 0; i < total; i++)); do + local accion="reinic" + if [[ "${plan_skip[$i]}" -eq 1 ]]; then + accion="OMITE" + else + act_count=$((act_count + 1)) + fi + [[ "${plan_status[$i]}" == "busy" ]] && busy_count=$((busy_count + 1)) + printf '%-8s %-9s %-7s %-6s %-38s %s\n' \ + "${plan_pid[$i]}" \ + "${plan_kitty[$i]:-(none)}" \ + "${plan_status[$i]}" \ + "$accion" \ + "${plan_sid[$i]}" \ + "${plan_cwd[$i]}" + if [[ "${plan_skip[$i]}" -eq 1 ]]; then + echo " -> omitida: ${plan_skipreason[$i]}" + else + echo " -> ${plan_cmd[$i]}" + fi + done + echo + + # Aviso explicito de sesiones busy que SI se van a reiniciar. + if [[ "$only_idle" -eq 0 ]]; then + local warned=0 + for ((i = 0; i < total; i++)); do + if [[ "${plan_skip[$i]}" -eq 0 && "${plan_status[$i]}" == "busy" ]]; then + if [[ "$warned" -eq 0 ]]; then + echo "AVISO: las siguientes sesiones estan BUSY y se reiniciaran; perderan el turno en vuelo" + echo " (al reanudar con --resume se recupera hasta el ultimo mensaje completo guardado):" + warned=1 + fi + echo " - PID ${plan_pid[$i]} cwd=${plan_cwd[$i]}" + fi + done + [[ "$warned" -eq 1 ]] && echo + fi + + # ----------------------------------------------------------------------- + # DRY-RUN: parar aqui. + # ----------------------------------------------------------------------- + if [[ "$mode" == "dry" ]]; then + echo "DRY-RUN: no se ha matado ni relanzado nada." + echo "Para ejecutar de verdad: reboot_all_claudes --go" + return 0 + fi + + if [[ "$act_count" -eq 0 ]]; then + echo "reboot_all_claudes: nada que hacer (todas las sesiones quedaron omitidas)." + return 0 + fi + + # ----------------------------------------------------------------------- + # MODO --go: construir un script desacoplado que mata las ventanas y + # relanza las sesiones. Se ejecuta con setsid para que sobreviva al cierre + # de la propia terminal (que es una de las victimas). + # ----------------------------------------------------------------------- + local ts script log + ts="$(date +%s)" + script="/tmp/reboot_all_claudes.$$.$ts.sh" + log="/tmp/reboot_all_claudes.$ts.log" + + { + echo '#!/usr/bin/env bash' + echo 'set -uo pipefail' + echo '# Dar tiempo a que la terminal padre devuelva el control antes de matar.' + echo 'sleep 1' + echo + for ((i = 0; i < total; i++)); do + [[ "${plan_skip[$i]}" -eq 1 ]] && continue + local kp="${plan_kitty[$i]}" + local cp="${plan_pid[$i]}" + local cwd="${plan_cwd[$i]}" + local cmd="${plan_cmd[$i]}" + echo "# --- sesion PID ${cp} (kitty ${kp:-none}) ---" + if [[ -n "$kp" ]]; then + # Cerrar la ventana kitty limpia con SIGTERM. + echo "kill $(printf '%q' "$kp") 2>/dev/null || true" + else + # Sin kitty: matar el propio claude. + echo "kill $(printf '%q' "$cp") 2>/dev/null || true" + fi + # Relanzar en una kitty nueva, detached, en el cwd correcto. + # zsh -ic '...; exec zsh' replica el patron del usuario: al salir de + # claude queda una shell interactiva viva. + printf 'setsid kitty --directory %q zsh -ic %q /dev/null 2>&1 &\n' \ + "$cwd" "${cmd}; exec zsh" + echo + done + echo 'exit 0' + } > "$script" + + chmod +x "$script" + echo "reboot_all_claudes: lanzando plan desacoplado -> $script (log: $log)" + setsid bash "$script" >"$log" 2>&1 & + disown 2>/dev/null || true + echo "reboot_all_claudes: hecho. Las terminales se cerraran y reabriran en ~1s." + return 0 +} + +# Permitir ejecutar el archivo directamente (no solo como funcion sourced). +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + reboot_all_claudes "$@" +fi diff --git a/docs/README.md b/docs/README.md index df6f7550..ce758d49 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,8 @@ Registry personal de código con búsqueda FTS. Diseñado para composición func - `integrity.md` — Reglas de integridad y referencias cruzadas - `architecture.md` — Visión general del sistema - `sync_setup.md` — Vincular una PC al server `registry.organic-machine.com` (env vars, `fn sync`, troubleshooting) +- `adr/` — Architecture Decision Records: decisiones de diseño (qué se decidió y por qué) +- `../reports/` — Reportes de trabajo: **artefacto local** (entregable de una tarea: qué se hizo, cómo se verificó, gaps). Gitignored salvo `.gitkeep`, NO sube a Gitea ni se versiona (como los vaults). Convención en `.claude/rules/reports.md`. Decisión: [ADR 0006](adr/0006-reports-folder.md) ## Tablas diff --git a/docs/adr/0006-reports-folder.md b/docs/adr/0006-reports-folder.md new file mode 100644 index 00000000..7ad09ebb --- /dev/null +++ b/docs/adr/0006-reports-folder.md @@ -0,0 +1,53 @@ +# ADR 0006 — `reports/` como artefacto local para reportes de trabajo + +- **Fecha:** 2026-06-06 +- **Estado:** accepted + +## Contexto + +Cuando un agente termina una tarea no trivial (una auditoría, una tanda de fixes con verificación, un refactor, una investigación), el resumen ejecutable —qué se hizo, cómo se verificó, qué quedó pendiente— vivía solo en el chat de la sesión. Eso tiene tres problemas: + +1. **Se pierde**: el chat no es consultable después; el resumen no queda en disco. +2. **No es compartible rápido**: para pasar el resultado hay que copiar a mano del chat. +3. **No tiene formato estable**: cada resumen sale distinto, sin garantía de evidencia ejecutable ni de declaración honesta de gaps. + +Los contenedores existentes no encajan: los ADRs (`docs/adr/`) son decisiones de diseño; las reglas (`.claude/rules/`) son normas operativas; el diario (`docs/diary/`) es bitácora cronológica libre. Faltaba un sitio para el **entregable de una tarea concreta**: el resultado y su evidencia. + +Punto clave de la decisión: un report **no es documentación del registry, es un artefacto** (en el sentido de `.claude/rules/artefactos.md`) — generado, con ciclo de vida propio, no código reutilizable. Y como artefacto del tipo "datos locales", se comporta como los **vaults**: no sube a Gitea ni se versiona en el git del padre. + +## Decisión + +Crear la carpeta `reports/` para reportes de trabajo, tratados como **artefacto local**: + +1. **No versionados, no Gitea.** `reports/*` está en el `.gitignore` del padre (solo `reports/.gitkeep` se versiona, para mantener la carpeta presente). Un report no tiene repo propio: vive local en la máquina que lo generó. Compartir = pasar la ruta o copiar el contenido, no `git push`. Mismo trato que los vaults. +2. **Conviven en raíz o en proyectos**, como cualquier artefacto: `reports/` (sueltos) o `projects/

/reports/` (del trabajo de un proyecto). Ambas rutas gitignored (`reports/*`, `projects/*/reports/`). Se permiten subcarpetas para agrupar. +3. **No se indexan en `registry.db`.** Sin tabla `reports` ni schema (KISS) — son texto plano efímero, como los `playgrounds`. +4. **Convención y plantilla** viven en `.claude/rules/reports.md` (versionado): nombre `NNNN-YYYY-MM-DD-slug.md`, secciones Resumen/Cambios/Verificación/Gaps, evidencia ejecutable obligatoria. + +Un report NO sustituye a un ADR ni a una regla: si durante el trabajo aparece una decisión de diseño, va a `docs/adr/` y el report solo la referencia. + +## Alternativas consideradas + +- **Versionar los reports en el repo padre.** Era el enfoque inicial de este ADR; descartado: un report es un artefacto (resultado de tarea, efímero, posiblemente voluminoso o ligado a un PC concreto), no documentación estable del registry. Versionarlos ensucia el historial del padre con entregables operativos. La convención correcta es la de los vaults: local, no Gitea. +- **Dejar los resúmenes solo en el chat.** Status quo; se pierden y no son compartibles. Es el motivo del ADR. +- **Usar `docs/diary/`.** El diario es cronológico, libre y versionado; mezclaría notas con entregables formales y no impone evidencia ejecutable. +- **Un ADR por tarea.** Sobrecarga el registro de decisiones con resultados operativos. +- **Indexar los reports en `registry.db`.** Añade schema y mantenimiento para un artefacto efímero. KISS: no se indexa, como los playgrounds. + +## Consecuencias + +- `.gitignore` del padre gana `reports/*` (con `!reports/.gitkeep`) y `projects/*/reports/`. +- Nueva regla `.claude/rules/reports.md` con convención + plantilla; entrada en `.claude/rules/INDEX.md`. +- `report` se añade como tipo de artefacto en `.claude/rules/artefactos.md` (NO indexado, NO sub-repo Gitea). +- Mención en la sección "Estructura" / "Artefactos" de `.claude/CLAUDE.md` y en `docs/README.md`. +- Los agentes pueden escribir un report al cerrar una tarea no trivial y pasar la ruta para compartir, en vez de volcar el resumen al chat. El report queda local (no viaja por git/`fn sync` salvo que el usuario lo copie aparte). +- Primer report: `projects/web_scraping/reports/0001-2026-06-06-browser-domain-audit-fixes.md` (local, gitignored; vive en el proyecto porque el trabajo tocó sus apps). Cada project que use reports añade `reports/*` (salvo `!reports/.gitkeep`) a su propio `.gitignore` para no subirlos a su Gitea. + +## Relación con otras reglas y ADRs + +- `.claude/rules/artefactos.md` — report es un tipo de artefacto; este ADR lo añade a la taxonomía. +- `.claude/rules/reports.md` — convención operativa derivada de este ADR. +- `.claude/rules/playgrounds.md` — mismo espíritu (artefacto local, no indexado). +- `.claude/rules/dod_quality.md` — los reports heredan su exigencia de evidencia ejecutable y gaps. +- [ADR 0002](0002-apps-analyses-as-dataforge-master.md) — apps/analyses SÍ son sub-repos Gitea; los reports NO (se parecen a los vaults, no a las apps). +- [ADR 0005](0005-keep-parent-git-lean.md) — mantener el `.git` del padre ligero; no versionar reports refuerza esa línea. diff --git a/docs/adr/README.md b/docs/adr/README.md index f3659f08..8bd95e22 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -63,3 +63,4 @@ Qué se aprendió después. Útil cuando un ADR se supersede. | [0003](0003-orphan-tu-as-separate-function-entry.md) | TU adicional de un parent function como entrada propia | accepted | | [0004](0004-telemetry-driven-capability-growth.md) | Telemetria de ejecuciones de Claude como motor de crecimiento del registry | accepted | | [0005](0005-keep-parent-git-lean.md) | Mantener el `.git` del padre ligero: no trackear artefactos hijos, purgar historial, submódulos shallow | accepted | +| [0006](0006-reports-folder.md) | Carpeta `reports/` para reportes de trabajo (entregable de tarea con evidencia) | accepted | diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index a5127338..cebdb59c 100644 --- a/docs/capabilities/INDEX.md +++ b/docs/capabilities/INDEX.md @@ -24,6 +24,7 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [docker](docker.md) | 38 | Operar Docker desde Go/Bash: build/run/stop, compose, networks, volumes, logs, deploys | | [android](android.md) | 37 | Toolbelt Android desde WSL2: adb, emuladores AVD, APK build/install, Capacitor, logcat | | [web-proxy](web-proxy.md) | 5 | Captura de trafico HTTP/HTTPS liviana (mitmproxy): proxy con rotacion, navegador proxeado, consulta de capturas, tee del SSE de claude. Alternativa ligera a ZAP/Burp | +| [flow-replay](flow-replay.md) | 3 | Guardar un flujo web (login, reiniciar server, formulario) como funcion reproducible: destila un HAR a call specs y lo reproduce sin navegador (HTTP puro), con fallback a chromium headless/visible. Consume las capturas de web-proxy | | [metabase](metabase.md) | 106 | Operar Metabase via API REST: auth, cards, dashboards, collections, snippets, permissions | | [doctor](doctor.md) | 11 | Diagnostico read-only del registry: artefactos, servicios, drift, funciones huerfanas | | [notebook](notebook.md) | 5 | Operar Jupyter Lab colaborativo (discover/read/exec/write/kernel) | diff --git a/docs/capabilities/flow-replay.md b/docs/capabilities/flow-replay.md new file mode 100644 index 00000000..07fb12d8 --- /dev/null +++ b/docs/capabilities/flow-replay.md @@ -0,0 +1,117 @@ +# Flow Replay — Guardar un flujo web como función reproducible + +Tag: `flow-replay`. Grupo de funciones para convertir un flujo de navegador que se hizo +una vez a mano (login en un panel, reiniciar un servidor, rellenar un formulario) en una +**función del registry reproducible sin intervención**. Materializa la doctrina del issue +0087: el registry crece promoviendo secuencias repetidas a operaciones de un solo paso. + +Filtro MCP: `mcp__registry__fn_search query="" tag="flow-replay"`. + +Complementa al grupo [`web-proxy`](web-proxy.md): `web-proxy` **graba** el tráfico, +`flow-replay` lo **destila y reproduce**. + +## El patrón: grabar → destilar → reproducir + +Tres fases, con una jerarquía de reproducción de más barato a más caro: + +``` +Fase 0 — GRABAR (una vez, siempre con browser + proxy) + web_proxy ON → haces la acción a mano en el navegador → exportas el tramo a HAR + (funciones del grupo web-proxy: start_mitm_capture, launch_chromium_proxy, query_mitm_flows --har) + +Fase 1 — DESTILAR (del HAR a una secuencia de requests) + har_filter_flows → descarta estáticos/analytics, deja los flujos que importan + har_extract_calls → normaliza cada flujo a una "call spec" reproducible (método, url, + headers, cookies, body), aislando los datos de auth + +Fase 2 — REPRODUCIR, en orden de preferencia: + Nivel 1 HTTP puro http_replay_sequence — rápido, headless, scriptable. PREFERIDO. + Nivel 2 headless chromium (fallback) — cuando hay token dinámico firmado en cliente, + challenge JS o WAF con fingerprint de navegador. Reutiliza + cdp_extract_recipe + cdp_save_storage_state (ver Fronteras). + Nivel 3 chromium visible + acciones humanizadas — último recurso si headless es detectado + (cdp_click_xy_human, cdp_move_mouse_human del dominio browser). +``` + +La función-acción concreta que guardas en el registry (`reboot__server`, +`login_`, etc.) envuelve el nivel que funcione: idealmente una llamada a +`http_replay_sequence` con su secuencia + parámetros, y los secretos resueltos desde +`pass`/vault. + +## Funciones del grupo + +| ID | Firma corta | Qué hace | +|---|---|---| +| [har_filter_flows_py_cybersecurity](../../python/functions/cybersecurity/har_filter_flows.md) | `har_filter_flows(har, *, hosts, methods, drop_static, drop_analytics) -> list[dict]` | Filtra un HAR: descarta recursos estáticos y hosts de telemetría, deja los flujos candidatos a "acción". Pura. | +| [har_extract_calls_py_cybersecurity](../../python/functions/cybersecurity/har_extract_calls.md) | `har_extract_calls(entries, *, drop_headers) -> list[dict]` | Convierte entries HAR en "call specs" normalizadas (método/url/headers/cookies/body/body_type), aislando cookies de auth y descartando headers hop-by-hop. Pura. | +| [http_replay_sequence_py_infra](../../python/functions/infra/http_replay_sequence.md) | `http_replay_sequence(calls, *, params, extract, timeout_s, verify_tls, allow_redirects, base_headers) -> dict` | Motor de replay HTTP: ejecuta la secuencia compartiendo cookie jar, substituye `{{param}}` y extrae valores de una respuesta para inyectarlos en pasos siguientes (flujo CSRF-like). Impura. | + +## Ejemplo canónico end-to-end + +Destilar un HAR capturado y reproducir el flujo sin navegador. Las tres funciones se +encadenan; la extracción del paso 1 (un token) se inyecta en el paso 2: + +```python +import sys, os +sys.path.insert(0, os.path.join("python", "functions")) +from cybersecurity.har_filter_flows import har_filter_flows +from cybersecurity.har_extract_calls import har_extract_calls +from infra.http_replay_sequence import http_replay_sequence + +# 1. HAR exportado por: query_mitm_flows ~/captures/traffic-*.mitm --har ~/sesion.har +import json +har = json.load(open(os.path.expanduser("~/sesion.har"))) + +# 2. Destilar: del ruido a la secuencia mínima +flows = har_filter_flows(har, hosts=["panel.midominio.com"]) # solo el host del panel +calls = har_extract_calls(flows) # call specs reproducibles + +# 3. Reproducir (Nivel 1, HTTP puro). El token del GET inicial se inyecta en el POST. +res = http_replay_sequence( + calls, + params={"server_id": "vps-42"}, # parametrizado por el caller + extract=[{"from": 0, "type": "json", "expr": "csrf", "as": "csrf"}], + verify_tls=True, +) +print(res["status"], [s["status_code"] for s in res["steps"]]) +``` + +Una vez validado, el flujo se promueve a una función-acción nombrada del registry +(p. ej. `reboot_vps_server_`) que internamente llama a `http_replay_sequence` +con su secuencia fija, recibe los parámetros del caller y resuelve los secretos desde +`pass`. Esa función-acción es lo que el agente invoca en un solo paso a partir de entonces. + +## Fronteras + +- **No graba**: la captura es del grupo [`web-proxy`](web-proxy.md). Este grupo empieza + con un HAR ya existente. +- **No auto-parametriza** (todavía). `har_extract_calls` normaliza pero NO detecta solo + qué valor es un token dinámico ni dónde se reinyecta. La parametrización (`{{param}}`) + y las reglas de `extract` las decide el humano/agente leyendo el HAR. La detección + automática de tokens/CSRF sería una función nueva del grupo, no una ampliación. +- **No incluye el runner de Nivel 2/3** (browser fallback). Está especificado en el + patrón pero no implementado: cuando un flujo real falle en HTTP puro, se construye un + "action recipe" reutilizando casi entero `cdp_extract_recipe_py_pipelines` (mismo + formato YAML, steps de acción en vez de extracción) + `cdp_save_storage_state_go_browser` + para saltarse el login. No se construye por adelantado (KISS / registry-first). +- **No gestiona secretos**: los secretos viajan como `{{param}}` desde `pass`/vault. El + grupo nunca los hardcodea ni los persiste. + +## Gotchas (seguridad — leer antes de usar) + +- **El HAR es sensible**: contiene cookies y tokens en crudo. Trátalo como un secreto — + gitignored, no subir a Gitea, no indexar, borrar tras destilar. El output de + `har_extract_calls` también lleva esos valores hasta que los sustituyes por `{{param}}`. +- **Secretos a `pass`/vault**, nunca en el código de la función-acción. +- **Replay con efectos = peligroso**: reproducir un POST que reinicia, borra o paga es + destructivo. La función-acción debe pedir confirmación o exponer un flag explícito + (`--yes`/`confirm=True`) antes de disparar. Nunca replay ciego de una acción irreversible. +- **HTTP puro no siempre reproduce**: token firmado en cliente, challenge JS, o WAF que + exige fingerprint de navegador → cae a Nivel 2 (headless) o 3 (visible humanizado). +- `http_replay_sequence` sigue redirects por defecto y `verify_tls=True`. La extracción + JSON es dot-path simple (`a.b.0.c`), no JSONPath completo. + +## Prerequisitos + +- Fase 0 (grabar): grupo `web-proxy` operativo (mitmproxy + chromium). Ver su página. +- Fase 1-2: `requests` en `python/.venv` (ya presente). Sin dependencias nuevas. diff --git a/functions/browser/cdp_click_ref.go b/functions/browser/cdp_click_ref.go index 6314fe8b..2597bafb 100644 --- a/functions/browser/cdp_click_ref.go +++ b/functions/browser/cdp_click_ref.go @@ -23,18 +23,49 @@ func refBoxCenter(c *CDPConn, backendNodeID int) (float64, float64, error) { return cx, cy, nil } -// CdpClickRef hace click humanizado (Bézier + jitter) sobre el elemento del #ref. -// El #ref es un backendDOMNodeId extraído del AX outline por page_perceive. +// CdpClickRef hace click sobre el elemento del #ref (un backendDOMNodeId extraído +// del AX outline por page_perceive). Por defecto usa click humanizado (Bézier + +// jitter) sobre el centro del bbox. Dos casos caen al click via element.click() JS: +// - opts.Mode == "instant": sin eventos de ratón reales (rápido, tests). +// - el nodo no tiene box model (display:contents, área 0): degradado natural en +// vez de fallar con error duro — un elemento clicable sin geometría sí se clica. // Hace scroll al elemento si es necesario antes de calcular las coordenadas. func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error { if c == nil { return fmt.Errorf("cdp click ref: conexión nil") } + if opts.Mode == "instant" { + return clickRefViaJS(c, backendNodeID) + } // scroll al elemento si no está visible; ignorar error (no fatal) _, _ = c.sendCDP("DOM.scrollIntoViewIfNeeded", map[string]any{"backendNodeId": backendNodeID}) cx, cy, err := refBoxCenter(c, backendNodeID) if err != nil { - return fmt.Errorf("cdp click ref: %w", err) + // Sin geometría: fallback a element.click() JS en vez de error duro. + return clickRefViaJS(c, backendNodeID) } return CdpClickXYHuman(c, cx, cy, opts) } + +// clickRefViaJS resuelve el nodo por backendDOMNodeId y llama element.click() en +// el contexto JS de la página. No dispara eventos de ratón reales (mousemove/ +// mousedown), por lo que algunos listeners de hover no se activan; a cambio +// funciona sin geometría y al instante. +func clickRefViaJS(c *CDPConn, backendNodeID int) error { + res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID}) + if err != nil { + return fmt.Errorf("cdp click ref (js): resolveNode ref %d: %w", backendNodeID, err) + } + obj, _ := res["object"].(map[string]any) + objID, _ := obj["objectId"].(string) + if objID == "" { + return fmt.Errorf("cdp click ref (js): sin objectId para ref %d", backendNodeID) + } + if _, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{ + "objectId": objID, + "functionDeclaration": "function(){ this.click(); }", + }); err != nil { + return fmt.Errorf("cdp click ref (js): click ref %d: %w", backendNodeID, err) + } + return nil +} diff --git a/functions/browser/cdp_click_xy_human.go b/functions/browser/cdp_click_xy_human.go index 450344e2..30afbe4a 100644 --- a/functions/browser/cdp_click_xy_human.go +++ b/functions/browser/cdp_click_xy_human.go @@ -37,8 +37,10 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error { return fmt.Errorf("cdp click xy human: mousePressed: %w", err) } - // Micro-pausa humana entre press y release (30-90 ms). - time.Sleep(time.Duration(30+rand.Intn(61)) * time.Millisecond) + // Pausa entre press y release según el modo de velocidad. + if pms := clickPauseMs(opts.Mode); pms > 0 { + time.Sleep(time.Duration(pms) * time.Millisecond) + } clickParams["type"] = "mouseReleased" if _, err := c.sendCDP("Input.dispatchMouseEvent", clickParams); err != nil { @@ -47,3 +49,16 @@ func CdpClickXYHuman(c *CDPConn, x, y float64, opts MouseHumanOpts) error { return nil } + +// clickPauseMs devuelve la pausa (ms) entre press y release según el modo de +// velocidad: human 30-90, fast 5-15, instant 0. +func clickPauseMs(mode string) int { + switch mode { + case "instant": + return 0 + case "fast": + return 5 + rand.Intn(11) // 5..15 + default: // "human" o "" + return 30 + rand.Intn(61) // 30..90 + } +} diff --git a/functions/browser/cdp_close.go b/functions/browser/cdp_close.go index d922406f..2416bac7 100644 --- a/functions/browser/cdp_close.go +++ b/functions/browser/cdp_close.go @@ -6,6 +6,21 @@ import ( "syscall" ) +// CdpDisconnect cierra SOLO la conexion WebSocket CDP, sin tocar el proceso +// Chrome. Es un alias legible de CdpClose(c, 0): usalo cuando quieras soltar la +// sesion pero dejar el navegador vivo (p.ej. el navegador diario en 9222 al que +// te adjuntaste, no quieres matarlo). +func CdpDisconnect(c *CDPConn) error { + return CdpClose(c, 0) +} + +// CdpQuit cierra la conexion WebSocket Y mata el proceso Chrome (y su grupo de +// proceso completo en Linux nativo). Es un alias legible de CdpClose(c, pid) con +// pid > 0: usalo para apagar un Chrome que TU lanzaste con ChromeLaunch. +func CdpQuit(c *CDPConn, pid int) error { + return CdpClose(c, pid) +} + // CdpClose cierra la conexion WebSocket CDP y, si pid > 0, mata el proceso Chrome. // En Linux nativo mata el grupo de proceso completo (chromium lanza zygote, gpu, // renderers como hijos del mismo grupo cuando ChromeLaunch seteo Setpgid: true). diff --git a/functions/browser/cdp_close.md b/functions/browser/cdp_close.md index 6f4831cf..ade9dad6 100644 --- a/functions/browser/cdp_close.md +++ b/functions/browser/cdp_close.md @@ -3,11 +3,11 @@ name: cdp_close kind: function lang: go domain: browser -version: "1.1.0" +version: "1.2.0" purity: impure signature: "func CdpClose(c *CDPConn, pid int) error" -description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle." -tags: [chrome, cdp, browser, automation, cleanup, devtools, linux] +description: "Cierra la conexion WebSocket CDP y opcionalmente mata el proceso Chrome por PID. En Linux nativo mata el grupo de proceso completo (pid == pgid cuando ChromeLaunch seteo Setpgid=true), lo que incluye zygote, gpu-process y renderers. Si c es nil, solo mata el proceso. Si pid <= 0, solo cierra la conexion. Siempre intenta ambas operaciones aunque una falle. Wrappers nombrados: CdpDisconnect(c) solo cierra el WebSocket; CdpQuit(c, pid) cierra y mata Chrome." +tags: [chrome, cdp, browser, automation, cleanup, devtools, linux, navegator] uses_functions: [] uses_types: [] returns: [] @@ -20,9 +20,9 @@ params: - name: pid desc: "PID del proceso Chrome (0 para no matar; en Linux nativo este PID es tambien el PGID cuando ChromeLaunch uso Setpgid)" output: "error si falla la desconexion o el cierre del proceso; nil si todo OK" -tested: false -tests: [] -test_file_path: "" +tested: true +tests: ["TestCdpCloseWrappers"] +test_file_path: "functions/browser/cdp_close_test.go" file_path: "functions/browser/cdp_close.go" --- @@ -43,6 +43,14 @@ defer CdpClose(nil, pid) // solo mata Chrome (y su grupo en Linux) Usar siempre en `defer` después de `ChromeLaunch` para garantizar cleanup del proceso Chrome y del WebSocket CDP. En Linux nativo mata el árbol completo de procesos (zygote, gpu, renderers) evitando procesos zombie. +**Elige el wrapper según la intención** (más legible que el `pid` mágico): + +| Quiero... | Usa | Equivale a | +|---|---|---| +| Soltar la sesión, dejar Chrome vivo (navegador diario en 9222) | `CdpDisconnect(c)` | `CdpClose(c, 0)` | +| Apagar el Chrome que yo lancé | `CdpQuit(c, pid)` | `CdpClose(c, pid)` | +| Control fino (decidir pid en runtime) | `CdpClose(c, pid)` | — | + ## Gotchas - **Kill por grupo (Linux nativo)**: usa `syscall.Kill(-pid, SIGKILL)` que envía la señal a todos los procesos del grupo. Funciona porque `ChromeLaunch` setea `Setpgid: true` en Linux, haciendo que `pid == pgid`. En WSL+chrome.exe el Setpgid no se aplica, por lo que el fallback a `os.FindProcess(pid).Kill()` maneja ese caso. @@ -56,4 +64,5 @@ Usar en `defer` para garantizar cleanup. Si tanto la conexion como el proceso so ## Capability growth log +- v1.2.0 (2026-06-06) — añade wrappers nombrados CdpDisconnect(c) (solo WebSocket) y CdpQuit(c, pid) (WebSocket + mata Chrome) para desambiguar el `pid` mágico; CdpClose sin cambios de comportamiento. - v1.1.0 (2026-06-05) — Linux-native kill: usa syscall.Kill(-pid, SIGKILL) para matar grupo completo (zygote, gpu, renderers), con fallback a os.FindProcess para WSL+exe o proceso ya terminado diff --git a/functions/browser/cdp_close_test.go b/functions/browser/cdp_close_test.go new file mode 100644 index 00000000..bf85f08b --- /dev/null +++ b/functions/browser/cdp_close_test.go @@ -0,0 +1,25 @@ +package browser + +import "testing" + +// TestCdpCloseWrappers es un smoke nil-safe de los wrappers nombrados. Sin Chrome: +// con conexión nil y pid 0 no hay nada que cerrar ni matar, así que no debe error. +func TestCdpCloseWrappers(t *testing.T) { + t.Run("CdpDisconnect(nil) no error (nada que cerrar)", func(t *testing.T) { + if err := CdpDisconnect(nil); err != nil { + t.Errorf("CdpDisconnect(nil) = %v, esperaba nil", err) + } + }) + + t.Run("CdpQuit(nil, 0) no error (sin conexion ni pid)", func(t *testing.T) { + if err := CdpQuit(nil, 0); err != nil { + t.Errorf("CdpQuit(nil, 0) = %v, esperaba nil", err) + } + }) + + t.Run("CdpClose(nil, 0) sigue siendo no-op", func(t *testing.T) { + if err := CdpClose(nil, 0); err != nil { + t.Errorf("CdpClose(nil, 0) = %v, esperaba nil", err) + } + }) +} diff --git a/functions/browser/cdp_conn.go b/functions/browser/cdp_conn.go index c6d8d426..e5eaffff 100644 --- a/functions/browser/cdp_conn.go +++ b/functions/browser/cdp_conn.go @@ -35,6 +35,12 @@ type CDPConn struct { closed bool handlers map[string][]EventHandler hMu sync.Mutex + + // frameCtx cachea el executionContextId del isolated world por frameID, para + // que CdpEvalInFrame no cree un mundo aislado nuevo en cada llamada. + // frameCtxMu protege solo el lazy-init del puntero (el cache tiene su mutex). + frameCtx *frameCtxCache + frameCtxMu sync.Mutex } type cdpRequest struct { diff --git a/functions/browser/cdp_eval_in_frame.go b/functions/browser/cdp_eval_in_frame.go index 6f31db02..18f9eaca 100644 --- a/functions/browser/cdp_eval_in_frame.go +++ b/functions/browser/cdp_eval_in_frame.go @@ -3,75 +3,119 @@ package browser import ( "encoding/json" "fmt" + "strings" + "sync" ) -// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe -// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. -// Retorna el resultado serializado como string. -func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) { - if c == nil { - return "", fmt.Errorf("cdp eval in frame: conexion nula") - } - if frameID == "" { - return "", fmt.Errorf("cdp eval in frame: frameID vacio") - } +// frameCtxCache mapea frameID -> executionContextId del isolated world creado +// para ese frame. Evita pagar Page.createIsolatedWorld en cada CdpEvalInFrame. +// Es puro y testeable de forma aislada (su propio mutex, sin tocar CDP). +type frameCtxCache struct { + mu sync.Mutex + m map[string]int +} - // Page.enable es idempotente; necesario antes de crear mundos aislados - if _, err := c.sendCDP("Page.enable", nil); err != nil { - return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err) - } +func newFrameCtxCache() *frameCtxCache { + return &frameCtxCache{m: map[string]int{}} +} - // Crear un mundo aislado en el frame indicado para no contaminar su contexto JS +func (f *frameCtxCache) get(frameID string) (int, bool) { + f.mu.Lock() + defer f.mu.Unlock() + id, ok := f.m[frameID] + return id, ok +} + +func (f *frameCtxCache) set(frameID string, ctxID int) { + f.mu.Lock() + f.m[frameID] = ctxID + f.mu.Unlock() +} + +func (f *frameCtxCache) invalidate(frameID string) { + f.mu.Lock() + delete(f.m, frameID) + f.mu.Unlock() +} + +// isStaleContextError reconoce el error de CDP cuando un executionContextId +// cacheado ya no existe (el frame recargó/navegó y su isolated world murió). Es +// puro: decide a partir del texto del error. Permite reintentar recreando el +// mundo en vez de fallar. +func isStaleContextError(err error) bool { + if err == nil { + return false + } + s := err.Error() + return strings.Contains(s, "Cannot find context") || + strings.Contains(s, "context with specified id") || + strings.Contains(s, "Execution context was destroyed") || + strings.Contains(s, "uniqueContextId") +} + +// frameCtxCacheLazy devuelve el cache de contextos del frame de esta conexion, +// inicializandolo en el primer uso. El mutex de CDPConn solo protege este +// lazy-init del puntero. +func (c *CDPConn) frameCtxCacheLazy() *frameCtxCache { + c.frameCtxMu.Lock() + defer c.frameCtxMu.Unlock() + if c.frameCtx == nil { + c.frameCtx = newFrameCtxCache() + } + return c.frameCtx +} + +// createIsolatedWorld crea un mundo aislado en el frame y devuelve su +// executionContextId. +func createIsolatedWorld(c *CDPConn, frameID string) (int, error) { ctxRes, err := c.sendCDP("Page.createIsolatedWorld", map[string]any{ - "frameId": frameID, - "worldName": "fn_registry_isolated", - "grantUniveralAccess": false, + "frameId": frameID, + "worldName": "fn_registry_isolated", + "grantUniversalAccess": false, }) if err != nil { - return "", fmt.Errorf("cdp eval in frame: createIsolatedWorld: %w", err) + return 0, fmt.Errorf("createIsolatedWorld: %w", err) } - ctxIDRaw, ok := ctxRes["executionContextId"] if !ok { - return "", fmt.Errorf("cdp eval in frame: executionContextId no encontrado en respuesta") + return 0, fmt.Errorf("createIsolatedWorld: executionContextId no encontrado en respuesta") } ctxID, ok := ctxIDRaw.(float64) if !ok { - return "", fmt.Errorf("cdp eval in frame: executionContextId tipo inesperado: %T", ctxIDRaw) + return 0, fmt.Errorf("createIsolatedWorld: executionContextId tipo inesperado: %T", ctxIDRaw) } + return int(ctxID), nil +} - // Evaluar la expresion en el contexto aislado del frame +// evalInFrameContext ejecuta la expresion en el executionContextId dado y +// serializa el resultado como string (mismo patron que CdpEvaluate). +func evalInFrameContext(c *CDPConn, ctxID int, frameID, expression string) (string, error) { evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{ "expression": expression, - "contextId": int(ctxID), + "contextId": ctxID, "returnByValue": true, "awaitPromise": true, }) if err != nil { - return "", fmt.Errorf("cdp eval in frame: Runtime.evaluate: %w", err) + return "", fmt.Errorf("Runtime.evaluate: %w", err) } - // Verificar excepcion JS if exc, ok := evRes["exceptionDetails"]; ok && exc != nil { excMap, _ := exc.(map[string]any) text, _ := excMap["text"].(string) - return "", fmt.Errorf("cdp eval in frame: excepcion JS en frame %q: %s", frameID, text) + return "", fmt.Errorf("excepcion JS en frame %q: %s", frameID, text) } - // Extraer valor del resultado (mismo patron que CdpEvaluate) resVal, ok := evRes["result"].(map[string]any) if !ok { - return "", fmt.Errorf("cdp eval in frame: resultado inesperado: %v", evRes) + return "", fmt.Errorf("resultado inesperado: %v", evRes) } value, ok := resVal["value"] if !ok { - // undefined u otro tipo no serializable typ, _ := resVal["type"].(string) return typ, nil } - - // Strings tal cual; objetos/arrays JS a JSON real (no la repr de Go de "%v"). if s, ok := value.(string); ok { return s, nil } @@ -81,3 +125,52 @@ func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) { } return string(b), nil } + +// CdpEvalInFrame ejecuta una expresion JavaScript en el contexto aislado de un iframe +// especifico usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. +// Retorna el resultado serializado como string. +// +// Cachea el executionContextId por frameID en la conexion: la primera llamada +// crea el mundo aislado, las siguientes lo reutilizan. Si el contexto cacheado +// caducó (el frame navegó/recargó), recrea el mundo una vez y reintenta. +func CdpEvalInFrame(c *CDPConn, frameID, expression string) (string, error) { + if c == nil { + return "", fmt.Errorf("cdp eval in frame: conexion nula") + } + if frameID == "" { + return "", fmt.Errorf("cdp eval in frame: frameID vacio") + } + + // Page.enable es idempotente; necesario antes de crear mundos aislados. + if _, err := c.sendCDP("Page.enable", nil); err != nil { + return "", fmt.Errorf("cdp eval in frame: Page.enable: %w", err) + } + + cache := c.frameCtxCacheLazy() + + ctxID, cached := cache.get(frameID) + if !cached { + newID, err := createIsolatedWorld(c, frameID) + if err != nil { + return "", fmt.Errorf("cdp eval in frame: %w", err) + } + ctxID = newID + cache.set(frameID, ctxID) + } + + out, evErr := evalInFrameContext(c, ctxID, frameID, expression) + if evErr != nil && cached && isStaleContextError(evErr) { + // El contexto cacheado murió (frame recargó). Recrear una vez. + cache.invalidate(frameID) + newID, err := createIsolatedWorld(c, frameID) + if err != nil { + return "", fmt.Errorf("cdp eval in frame: %w", err) + } + cache.set(frameID, newID) + out, evErr = evalInFrameContext(c, newID, frameID, expression) + } + if evErr != nil { + return "", fmt.Errorf("cdp eval in frame: %w", evErr) + } + return out, nil +} diff --git a/functions/browser/cdp_eval_in_frame.md b/functions/browser/cdp_eval_in_frame.md index 2fb454aa..63b1cf88 100644 --- a/functions/browser/cdp_eval_in_frame.md +++ b/functions/browser/cdp_eval_in_frame.md @@ -5,9 +5,11 @@ kind: function lang: go domain: browser purity: impure -version: 1.0.0 -tested: false -description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame." +version: 1.1.0 +tested: true +tests: ["TestCdpEvalInFrame_guards", "TestFrameCtxCache", "TestIsStaleContextError"] +test_file_path: "functions/browser/cdp_eval_in_frame_test.go" +description: "Ejecuta una expresión JavaScript en el contexto aislado de un iframe concreto usando Page.createIsolatedWorld + Runtime.evaluate con el contextId del frame. Cachea el executionContextId por frameID en la conexión para no recrear el isolated world en cada llamada; si el contexto caduca (frame recargó) lo recrea una vez y reintenta." tags: [cdp, browser, iframe, javascript, eval, navegator] signature: "func CdpEvalInFrame(c *CDPConn, frameID string, expression string) (string, error)" uses_functions: [] @@ -71,3 +73,8 @@ Cuando necesites leer o manipular el DOM de un iframe específico sin afectar el - Si el iframe tiene `sandbox` attribute sin `allow-scripts`, el CDP puede crear el mundo aislado pero las evaluaciones fallarán con excepción de seguridad. - Cross-origin iframes en Chrome permiten evaluación CDP siempre que la conexión tenga acceso al target; no aplican las restricciones CORS de JS normal. - El `frameID` debe obtenerse con `CdpListFrames`; si se pasa un ID obsoleto (frame recargado o destruido), `createIsolatedWorld` retorna error. +- **Cache de contexto por frameID**: la primera llamada crea el isolated world; las siguientes reutilizan su `executionContextId` (más rápido). Si el frame navega/recarga, el contexto cacheado caduca; la función detecta el error ("Cannot find context", "Execution context was destroyed") y recrea el mundo una vez automáticamente. El cache vive en la conexión: persiste entre llamadas mientras la conexión esté viva. + +## Capability growth log + +- v1.1.0 (2026-06-06) — corrige typo `grantUniveralAccess` → `grantUniversalAccess` (la opción nunca se aplicaba); cachea executionContextId por frameID en CDPConn (vía `frameCtxCache`) para no crear un isolated world por llamada; recrea+reintenta una vez si el contexto cacheado caducó. diff --git a/functions/browser/cdp_eval_in_frame_test.go b/functions/browser/cdp_eval_in_frame_test.go new file mode 100644 index 00000000..91100bb3 --- /dev/null +++ b/functions/browser/cdp_eval_in_frame_test.go @@ -0,0 +1,79 @@ +package browser + +import ( + "errors" + "testing" +) + +// TestCdpEvalInFrame_guards cubre precondiciones sin Chrome. +func TestCdpEvalInFrame_guards(t *testing.T) { + t.Run("conexion nula", func(t *testing.T) { + if _, err := CdpEvalInFrame(nil, "f1", "1"); err == nil { + t.Fatal("esperaba error con conexion nula") + } + }) + t.Run("frameID vacio", func(t *testing.T) { + if _, err := CdpEvalInFrame(&CDPConn{}, "", "1"); err == nil { + t.Fatal("esperaba error con frameID vacio") + } + }) +} + +// TestFrameCtxCache cubre el núcleo puro del cache de contextos por frame. +func TestFrameCtxCache(t *testing.T) { + t.Run("golden: set/get devuelve el ctxId cacheado", func(t *testing.T) { + c := newFrameCtxCache() + if _, ok := c.get("frameA"); ok { + t.Fatal("cache recién creado no debería tener frameA") + } + c.set("frameA", 42) + id, ok := c.get("frameA") + if !ok || id != 42 { + t.Fatalf("get(frameA) = (%d,%v), esperaba (42,true)", id, ok) + } + }) + + t.Run("edge: frames distintos no se pisan", func(t *testing.T) { + c := newFrameCtxCache() + c.set("frameA", 1) + c.set("frameB", 2) + if id, _ := c.get("frameA"); id != 1 { + t.Errorf("frameA = %d, esperaba 1", id) + } + if id, _ := c.get("frameB"); id != 2 { + t.Errorf("frameB = %d, esperaba 2", id) + } + }) + + t.Run("invalidate: tras invalidar, get falla (fuerza recrear mundo)", func(t *testing.T) { + c := newFrameCtxCache() + c.set("frameA", 7) + c.invalidate("frameA") + if _, ok := c.get("frameA"); ok { + t.Error("tras invalidate, get(frameA) debería fallar") + } + }) +} + +// TestIsStaleContextError cubre el discriminador puro que decide si reintentar +// recreando el isolated world. +func TestIsStaleContextError(t *testing.T) { + cases := []struct { + name string + err error + want bool + }{ + {"nil no es stale", nil, false}, + {"error generico no es stale", errors.New("boom"), false}, + {"Cannot find context es stale", errors.New("cdp error: Cannot find context with specified id"), true}, + {"Execution context was destroyed es stale", errors.New("Execution context was destroyed"), true}, + {"uniqueContextId es stale", errors.New("invalid uniqueContextId"), true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := isStaleContextError(tc.err); got != tc.want { + t.Errorf("isStaleContextError(%v) = %v, esperaba %v", tc.err, got, tc.want) + } + }) + } +} diff --git a/functions/browser/cdp_find_ref_by_text.go b/functions/browser/cdp_find_ref_by_text.go new file mode 100644 index 00000000..f846e8d5 --- /dev/null +++ b/functions/browser/cdp_find_ref_by_text.go @@ -0,0 +1,141 @@ +package browser + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" +) + +// findByTextCoreJS es el preludio JS compartido por las dos evaluaciones de +// CdpFindRefByText: define norm/matches/leafmost y la lista de nodos candidatos. +// Mismo algoritmo "leafmost" que CdpFindByText: prefiere el elemento más interno +// que matchea (donde suele vivir el handler), no el contenedor que lo envuelve. +const findByTextCoreJS = ` + var P = %s; + var target = P.cs ? P.text : P.text.toLowerCase(); + var nodes = document.querySelectorAll(P.tag || '*'); + function norm(v) { + v = (v || '').replace(/\s+/g, ' ').trim(); + return P.cs ? v : v.toLowerCase(); + } + function matches(el) { + var v = norm(el.innerText || el.textContent || ''); + return P.exact ? v === target : v.indexOf(target) >= 0; + } + function leafmost(el) { + for (var i = 0; i < el.children.length; i++) { + if (matches(el.children[i])) return false; + } + return true; + }` + +// parseBackendNodeID extrae node.backendNodeId de la respuesta de DOM.describeNode. +// Es puro: recibe el mapa ya deserializado por CDP y devuelve el id entero, o un +// error claro si la estructura no es la esperada (nodo destruido, respuesta vacía). +func parseBackendNodeID(resp map[string]any) (int, error) { + node, ok := resp["node"].(map[string]any) + if !ok { + return 0, fmt.Errorf("describeNode: respuesta sin campo node") + } + raw, ok := node["backendNodeId"] + if !ok { + return 0, fmt.Errorf("describeNode: node sin backendNodeId") + } + f, ok := raw.(float64) + if !ok { + return 0, fmt.Errorf("describeNode: backendNodeId tipo inesperado %T", raw) + } + return int(f), nil +} + +// CdpFindRefByText busca el primer elemento cuyo innerText matchea `text` y +// devuelve su backendDOMNodeId — el mismo identificador estable (#ref) que +// produce el outline de page_perceive y que consume CdpClickRef. Así se puede +// hacer click-by-text sin pasar por un selector CSS frágil (nth-of-type). +// +// Retorna (backendNodeID, count, error): +// - backendNodeID: ref del primer match, listo para CdpClickRef/CdpHoverRef. +// - count: número total de elementos que matchean (tras el filtro leafmost). +// count > 1 indica ambigüedad: el caller decide si refinar la búsqueda. +// - error: si la conexión es nula, el texto vacío, el eval JS falla o no hay +// ningún match (count == 0). +// +// Identidad unificada con el puente backendDOMNodeId: resuelve el nodo JS a un +// RemoteObject (Runtime.evaluate returnByValue=false) y de ahí al nodo DOM +// (DOM.describeNode), evitando el round-trip por selector CSS. +func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error) { + if c == nil { + return 0, 0, fmt.Errorf("cdp find ref by text: conexion nula") + } + if text == "" { + return 0, 0, fmt.Errorf("cdp find ref by text: texto vacio") + } + + payload, _ := json.Marshal(map[string]any{ + "text": text, + "tag": opts.Tag, + "exact": opts.Exact, + "cs": opts.CaseSensitive, + }) + core := fmt.Sprintf(findByTextCoreJS, string(payload)) + + // 1. Contar matches (returnByValue=true vía CdpEvaluate). + countJS := "(function(){" + core + ` + var n = 0; + for (var i = 0; i < nodes.length; i++) { + if (matches(nodes[i]) && leafmost(nodes[i])) n++; + } + return n; +})()` + countStr, err := CdpEvaluate(c, countJS) + if err != nil { + return 0, 0, fmt.Errorf("cdp find ref by text: contar matches: %w", err) + } + count, _ := strconv.Atoi(strings.TrimSpace(countStr)) + if count == 0 { + return 0, 0, fmt.Errorf("cdp find ref by text: no se encontro elemento con texto %q", text) + } + + // 2. Resolver el primer match a un RemoteObject (returnByValue=false para + // obtener un objectId que apunta al nodo DOM vivo). + elJS := "(function(){" + core + ` + for (var i = 0; i < nodes.length; i++) { + if (matches(nodes[i]) && leafmost(nodes[i])) return nodes[i]; + } + return null; +})()` + evRes, err := c.sendCDP("Runtime.evaluate", map[string]any{ + "expression": elJS, + "returnByValue": false, + }) + if err != nil { + return 0, count, fmt.Errorf("cdp find ref by text: evaluate elemento: %w", err) + } + if exc, ok := evRes["exceptionDetails"]; ok && exc != nil { + excMap, _ := exc.(map[string]any) + txt, _ := excMap["text"].(string) + return 0, count, fmt.Errorf("cdp find ref by text: excepcion JS: %s", txt) + } + remote, ok := evRes["result"].(map[string]any) + if !ok { + return 0, count, fmt.Errorf("cdp find ref by text: respuesta evaluate sin result") + } + objID, _ := remote["objectId"].(string) + if objID == "" { + // El conteo dio >0 pero el elemento desapareció entre ambos evals (DOM + // mutó): tratamos como no encontrado para no devolver un ref inválido. + return 0, count, fmt.Errorf("cdp find ref by text: elemento volátil, sin objectId (el DOM cambió entre conteo y resolución)") + } + + // 3. Del RemoteObject al nodo DOM: backendNodeId. + dn, err := c.sendCDP("DOM.describeNode", map[string]any{"objectId": objID}) + if err != nil { + return 0, count, fmt.Errorf("cdp find ref by text: describeNode: %w", err) + } + backendNodeID, err := parseBackendNodeID(dn) + if err != nil { + return 0, count, fmt.Errorf("cdp find ref by text: %w", err) + } + return backendNodeID, count, nil +} diff --git a/functions/browser/cdp_find_ref_by_text.md b/functions/browser/cdp_find_ref_by_text.md new file mode 100644 index 00000000..318f6a98 --- /dev/null +++ b/functions/browser/cdp_find_ref_by_text.md @@ -0,0 +1,58 @@ +--- +name: cdp_find_ref_by_text +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpFindRefByText(c *CDPConn, text string, opts FindByTextOpts) (int, int, error)" +description: "Busca el primer elemento cuyo innerText matchea el texto dado y devuelve su backendDOMNodeId (#ref estable) en vez de un selector CSS. Resuelve el nodo JS a RemoteObject (Runtime.evaluate returnByValue=false) y de ahi al nodo DOM (DOM.describeNode), unificando la identidad con page_perceive y CdpClickRef. Devuelve tambien el numero de matches para detectar ambiguedad. Prefiere elementos hoja (leafmost)." +tags: [browser, cdp, find, locator, ref, accessibility, navegator] +uses_functions: + - cdp_evaluate_go_browser +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [encoding/json, fmt, strconv, strings] +params: + - name: c + desc: "Conexion CDP activa obtenida con CdpConnect." + - name: text + desc: "Texto visible a buscar. Comparacion contra innerText/textContent normalizado (whitespace colapsado)." + - name: opts + desc: "FindByTextOpts: Tag (filtro por tag, vacio = cualquiera), Exact (default false), CaseSensitive (default false)." +output: "(backendNodeID, count, error): backendNodeID es el #ref del primer match listo para CdpClickRef; count es el numero total de matches (>1 = ambiguo); error si conexion nula, texto vacio, eval JS falla o no hay match (count==0)." +tested: true +tests: ["TestCdpFindRefByText_guards", "TestParseBackendNodeID"] +test_file_path: "functions/browser/cdp_find_ref_by_text_test.go" +file_path: "functions/browser/cdp_find_ref_by_text.go" +--- + +## Ejemplo + +```go +c, _ := browser.CdpConnect(9222) +defer browser.CdpClose(c, 0) + +// Encontrar el botón "Login" por su texto y clicar por #ref (sin selector CSS). +ref, count, err := browser.CdpFindRefByText(c, "Login", browser.FindByTextOpts{Tag: "button"}) +if err != nil { + log.Fatal(err) +} +if count > 1 { + log.Printf("aviso: %d elementos matchean 'Login', usando el primero", count) +} +_ = browser.CdpClickRef(c, ref, browser.MouseProfileForMode("human")) +``` + +## Cuando usarla + +Cuando quieras clicar/hacer hover sobre un elemento identificándolo por su texto visible y operar después por `#ref` (backendDOMNodeId) en vez de por un selector CSS frágil. Es el puente entre "lo veo por su texto" y el bucle percibir→actuar de `page_perceive` + `CdpClickRef`. Preferible a `cdp_find_by_text` (que devuelve selector `nth-of-type`) cuando el frontend cambia sus clases/estructura con cada build pero el texto es estable. + +## Gotchas + +- **count > 1 = ambigüedad**: la función devuelve el primer match pero te avisa con `count` cuántos hay. Refina con `opts.Tag` o `opts.Exact` si el texto aparece en varios sitios. +- **Elemento volátil**: si el DOM muta entre el conteo y la resolución del nodo (SPA re-renderizando), el `objectId` puede venir vacío y la función devuelve error "elemento volátil" en vez de un `#ref` inválido. Reintenta tras `CdpWaitIdle`. +- **El #ref es efímero por documento**: el `backendDOMNodeId` es estable mientras el nodo viva, pero se invalida tras navegar o recargar. No lo persistas entre páginas. +- **Tests sin Chrome**: el núcleo puro (`parseBackendNodeID`) y los guards se testean sin navegador. El flujo completo (eval + describeNode contra DOM real) requiere Chrome y se valida por e2e. diff --git a/functions/browser/cdp_find_ref_by_text_test.go b/functions/browser/cdp_find_ref_by_text_test.go new file mode 100644 index 00000000..cdd36835 --- /dev/null +++ b/functions/browser/cdp_find_ref_by_text_test.go @@ -0,0 +1,70 @@ +package browser + +import ( + "strings" + "testing" +) + +// TestCdpFindRefByText_guards cubre las precondiciones (sin Chrome). +func TestCdpFindRefByText_guards(t *testing.T) { + t.Run("conexion nula", func(t *testing.T) { + if _, _, err := CdpFindRefByText(nil, "x", FindByTextOpts{}); err == nil { + t.Fatal("esperaba error con conexion nula") + } + }) + t.Run("texto vacio", func(t *testing.T) { + c := &CDPConn{} + _, _, err := CdpFindRefByText(c, "", FindByTextOpts{}) + if err == nil { + t.Fatal("esperaba error con texto vacio") + } + if !strings.Contains(err.Error(), "vacio") { + t.Fatalf("mensaje %q no menciona vacio", err.Error()) + } + }) +} + +// TestParseBackendNodeID cubre el nucleo puro que convierte la respuesta de +// DOM.describeNode en el backendNodeId entero. No requiere Chrome. +func TestParseBackendNodeID(t *testing.T) { + t.Run("golden: node con backendNodeId", func(t *testing.T) { + resp := map[string]any{ + "node": map[string]any{"backendNodeId": 123.0, "nodeName": "BUTTON"}, + } + id, err := parseBackendNodeID(resp) + if err != nil { + t.Fatalf("error inesperado: %v", err) + } + if id != 123 { + t.Fatalf("id = %d, esperaba 123", id) + } + }) + + t.Run("edge: backendNodeId grande se trunca a int correctamente", func(t *testing.T) { + resp := map[string]any{"node": map[string]any{"backendNodeId": 90001.0}} + id, err := parseBackendNodeID(resp) + if err != nil || id != 90001 { + t.Fatalf("id=%d err=%v, esperaba 90001 sin error", id, err) + } + }) + + t.Run("error: respuesta sin node", func(t *testing.T) { + if _, err := parseBackendNodeID(map[string]any{}); err == nil { + t.Error("esperaba error cuando falta node") + } + }) + + t.Run("error: node sin backendNodeId", func(t *testing.T) { + resp := map[string]any{"node": map[string]any{"nodeName": "DIV"}} + if _, err := parseBackendNodeID(resp); err == nil { + t.Error("esperaba error cuando falta backendNodeId") + } + }) + + t.Run("error: backendNodeId tipo no numerico", func(t *testing.T) { + resp := map[string]any{"node": map[string]any{"backendNodeId": "abc"}} + if _, err := parseBackendNodeID(resp); err == nil { + t.Error("esperaba error cuando backendNodeId no es numero") + } + }) +} diff --git a/functions/browser/cdp_get_ax_outline.go b/functions/browser/cdp_get_ax_outline.go new file mode 100644 index 00000000..a9f5f61c --- /dev/null +++ b/functions/browser/cdp_get_ax_outline.go @@ -0,0 +1,409 @@ +package browser + +import ( + "fmt" + "strings" +) + +// axoActionableRoles son los roles que el LLM puede referir con #ref. Misma +// lista que _ACTIONABLE_ROLES de render_ax_outline.py. +var axoActionableRoles = map[string]struct{}{ + "button": {}, + "link": {}, + "textbox": {}, + "searchbox": {}, + "checkbox": {}, + "radio": {}, + "combobox": {}, + "listbox": {}, + "menuitem": {}, + "menuitemcheckbox": {}, + "menuitemradio": {}, + "tab": {}, + "option": {}, + "switch": {}, + "slider": {}, + "spinbutton": {}, + "treeitem": {}, + "gridcell": {}, +} + +// axoSkipRoles son roles sin valor semantico: se omiten y sus hijos se elevan al +// nivel actual. Misma lista que _SKIP_ROLES de render_ax_outline.py. +var axoSkipRoles = map[string]struct{}{ + "none": {}, + "presentation": {}, + "ignored": {}, +} + +// axoMaxDepth limita la profundidad de render (guard anti-RecursionError de +// arboles AX patologicos). Igual que _MAX_DEPTH del .py. +const axoMaxDepth = 60 + +// axNode es la representacion interna de un AXNode CDP, ya extraida del +// map[string]any de la respuesta. Los helpers de poda y render operan sobre +// estos structs, lo que los hace puros y testeables sin Chrome. +type axNode struct { + nodeID string + backendDOMNodeID string + ignored bool + role string + name string + value string + childIDs []string + parentID string +} + +// CdpGetAXOutline percibe la pagina (o un iframe concreto via frameID) como un +// outline accesible indentado y accionable, reusando la conexion CDP viva del +// pool — sin abrir un WebSocket nuevo ni levantar el venv de Python. +// +// Envia Accessibility.enable (idempotente) y Accessibility.getFullAXTree. Si +// frameID != "", pasa {"frameId": frameID} para obtener el arbol DENTRO de ese +// iframe; con frameID == "" obtiene el arbol completo de la pagina (depth -1). +// +// El resultado se poda (trim) y luego se renderiza replicando exactamente el +// formato del pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline: +// indentacion de 2 espacios por nivel, `role "name"`, ` = 'value'` para inputs, +// y marcador ` #ref=` en roles accionables. maxChars > 0 +// trunca y añade "\n…[outline truncado]"; maxChars <= 0 = sin limite. +func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error) { + if c == nil { + return "", fmt.Errorf("cdp get ax outline: conexion nula") + } + + // Accessibility.enable es idempotente; necesario antes de getFullAXTree. + if _, err := c.sendCDP("Accessibility.enable", nil); err != nil { + return "", fmt.Errorf("cdp get ax outline: Accessibility.enable: %w", err) + } + + var params map[string]any + if frameID != "" { + params = map[string]any{"frameId": frameID} + } + + res, err := c.sendCDP("Accessibility.getFullAXTree", params) + if err != nil { + return "", fmt.Errorf("cdp get ax outline: Accessibility.getFullAXTree: %w", err) + } + + nodes := axoParseNodes(res) + trimmed := trimAXTree(nodes) + return renderAXOutline(trimmed, maxChars), nil +} + +// axoParseNodes extrae la lista de axNode del result de getFullAXTree. Tras el +// JSON unmarshal a map[string]any, los nodos vienen como []any de +// map[string]any y los enteros (backendDOMNodeId, nodeId) como float64; nodeId y +// childIds suelen llegar como strings. Normalizamos todo a string. +func axoParseNodes(result map[string]any) []axNode { + raw, ok := result["nodes"].([]any) + if !ok { + return nil + } + out := make([]axNode, 0, len(raw)) + for _, item := range raw { + m, ok := item.(map[string]any) + if !ok { + continue + } + n := axNode{ + nodeID: axoStr(m["nodeId"]), + backendDOMNodeID: axoStr(m["backendDOMNodeId"]), + ignored: axoBool(m["ignored"]), + role: axoNested(m["role"]), + name: axoNested(m["name"]), + value: axoNested(m["value"]), + childIDs: axoStrSlice(m["childIds"]), + parentID: axoStr(m["parentId"]), + } + out = append(out, n) + } + return out +} + +// axoNested extrae el campo "value" de un objeto CDP del tipo {value: ...} (role, +// name, value vienen asi). Devuelve "" si esta ausente o vacio. +func axoNested(v any) string { + m, ok := v.(map[string]any) + if !ok { + if v == nil { + return "" + } + return axoStr(v) + } + return axoStr(m["value"]) +} + +// axoStr normaliza cualquier escalar JSON a string. Los enteros CDP llegan como +// float64 tras el unmarshal; los renderizamos sin decimales. +func axoStr(v any) string { + switch t := v.(type) { + case nil: + return "" + case string: + return t + case float64: + // IDs CDP son enteros: evitar notacion 1.234e+06 / sufijo .0. + return fmt.Sprintf("%d", int64(t)) + case bool: + if t { + return "true" + } + return "false" + default: + return fmt.Sprintf("%v", t) + } +} + +func axoBool(v any) bool { + b, _ := v.(bool) + return b +} + +func axoStrSlice(v any) []string { + raw, ok := v.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(raw)) + for _, item := range raw { + out = append(out, axoStr(item)) + } + return out +} + +// trimAXTree compacta la lista de axNode descartando nodos irrelevantes y +// colapsando cadenas padre->hijo del mismo role. Puro: porta trim_ax_tree.py. +// +// Descarta: ignored=true; role 'generic'/'none' sin name ni childIds; +// role 'StaticText' con name vacio. Colapsa: nodo con exactamente 1 hijo del +// mismo role hereda los childIds del hijo (el hijo se descarta). Itera hasta +// convergencia. Preserva el orden original de aparicion. +func trimAXTree(nodes []axNode) []axNode { + if len(nodes) == 0 { + return nil + } + + shouldDiscard := func(n axNode) bool { + if n.ignored { + return true + } + if (n.role == "generic" || n.role == "none") && n.name == "" && len(n.childIDs) == 0 { + return true + } + if n.role == "StaticText" && n.name == "" { + return true + } + return false + } + + byID := map[string]axNode{} + for _, n := range nodes { + if shouldDiscard(n) { + continue + } + byID[n.nodeID] = n + } + + // Colapso iterativo hasta convergencia. + for { + changed := false + removed := map[string]struct{}{} + for _, node := range byID { + if _, gone := removed[node.nodeID]; gone { + continue + } + if len(node.childIDs) != 1 { + continue + } + childID := node.childIDs[0] + child, ok := byID[childID] + if !ok || child.role != node.role { + continue + } + // Fusionar: el padre hereda los childIds del hijo. + merged := node + merged.childIDs = child.childIDs + byID[node.nodeID] = merged + removed[childID] = struct{}{} + changed = true + } + if !changed { + break + } + for id := range removed { + delete(byID, id) + } + } + + // Preservar orden original. + result := make([]axNode, 0, len(byID)) + seen := map[string]struct{}{} + for _, n := range nodes { + node, ok := byID[n.nodeID] + if !ok { + continue + } + if _, dup := seen[n.nodeID]; dup { + continue + } + result = append(result, node) + seen[n.nodeID] = struct{}{} + } + return result +} + +// renderAXOutline convierte axNode en un outline indentado, legible y +// accionable. Puro: porta render_ax_outline.py al caracter. La jerarquia se +// reconstruye con childIDs; las raices son nodeIds que no aparecen como hijo de +// nadie (fallback al primer nodo). maxChars > 0 trunca con sufijo. +func renderAXOutline(nodes []axNode, maxChars int) string { + if len(nodes) == 0 { + return "" + } + + byID := map[string]axNode{} + for _, n := range nodes { + if n.nodeID != "" { + byID[n.nodeID] = n + } + } + + allChildIDs := map[string]struct{}{} + for _, n := range nodes { + for _, cid := range n.childIDs { + allChildIDs[cid] = struct{}{} + } + } + + var roots []axNode + for _, n := range nodes { + if _, isChild := allChildIDs[n.nodeID]; !isChild { + roots = append(roots, n) + } + } + if len(roots) == 0 { + roots = []axNode{nodes[0]} + } + + var lines []string + visited := map[string]struct{}{} // guard de ciclo: un nodeId no se renderiza dos veces + + var renderNode func(node axNode, depth int) + renderNode = func(node axNode, depth int) { + nid := node.nodeID + if depth > axoMaxDepth { + return + } + if nid != "" { + if _, dup := visited[nid]; dup { + return + } + visited[nid] = struct{}{} + } + + if node.ignored { + return + } + + role := node.role + if _, skip := axoSkipRoles[role]; role == "" || skip { + // Nodos sin role util: elevar los hijos al nivel actual. + for _, cid := range node.childIDs { + if child, ok := byID[cid]; ok { + renderNode(child, depth) + } + } + return + } + + indent := strings.Repeat(" ", depth) + var base string + if node.name != "" { + base = fmt.Sprintf("%s%s %q", indent, role, node.name) + } else { + base = indent + role + } + + // Estado actual del campo (texto escrito, valor de slider/combobox). + if node.value != "" { + base += " = " + axoPyRepr(node.value) + } + + // Ref accionable, sin padding. + if _, ok := axoActionableRoles[role]; ok { + ref := axoRefID(node) + if ref != "" { + base += " #ref=" + ref + } + } + + lines = append(lines, base) + + for _, cid := range node.childIDs { + if child, ok := byID[cid]; ok { + renderNode(child, depth+1) + } + } + } + + for _, root := range roots { + renderNode(root, 0) + } + + result := strings.Join(lines, "\n") + + if maxChars > 0 && len(result) > maxChars { + result = strings.TrimRight(result[:maxChars], " \t\n\r\v\f") + result += "\n…[outline truncado]" + } + + return result +} + +// axoRefID devuelve el ref estable del nodo: backendDOMNodeId (apunta al nodo DOM +// real, estable mientras el nodo viva) con fallback al nodeId. Igual que +// _ref_id() del .py. +func axoRefID(n axNode) string { + if n.backendDOMNodeID != "" { + return n.backendDOMNodeID + } + return n.nodeID +} + +// axoPyRepr replica Python repr() para strings: comillas simples por defecto; +// comillas dobles si la cadena contiene comilla simple pero no doble; escape de +// backslash y de la comilla delimitadora. Reproduce el efecto de `{value!r}` +// del render_ax_outline.py para que la salida coincida al caracter. +func axoPyRepr(s string) string { + hasSingle := strings.Contains(s, "'") + hasDouble := strings.Contains(s, "\"") + quote := byte('\'') + if hasSingle && !hasDouble { + quote = '"' + } + + var b strings.Builder + b.WriteByte(quote) + for i := 0; i < len(s); i++ { + ch := s[i] + switch ch { + case '\\': + b.WriteString("\\\\") + case '\n': + b.WriteString("\\n") + case '\r': + b.WriteString("\\r") + case '\t': + b.WriteString("\\t") + case quote: + b.WriteByte('\\') + b.WriteByte(quote) + default: + b.WriteByte(ch) + } + } + b.WriteByte(quote) + return b.String() +} diff --git a/functions/browser/cdp_get_ax_outline.md b/functions/browser/cdp_get_ax_outline.md new file mode 100644 index 00000000..ac090ace --- /dev/null +++ b/functions/browser/cdp_get_ax_outline.md @@ -0,0 +1,64 @@ +--- +id: cdp_get_ax_outline_go_browser +name: cdp_get_ax_outline +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpGetAXOutline(c *CDPConn, frameID string, maxChars int) (string, error)" +description: "Percibe la pagina (o un iframe via frameID) como outline accesible indentado y accionable reusando la conexion CDP viva del pool. Envia Accessibility.enable + getFullAXTree, poda el arbol y lo renderiza con #ref=backendDOMNodeId en roles accionables. Replica al caracter el pipeline Python cdp_get_ax_tree -> trim_ax_tree -> render_ax_outline pero nativo en Go, sin subprocess ni venv." +tags: [browser, cdp, ax, accessibility, perceive, iframe, navegator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: true +tests: ["TestRenderAXOutline_ActionableRoleCarriesRef", "TestRenderAXOutline_InputShowsValue", "TestRenderAXOutline_SkipRoleElevatesChildren", "TestRenderAXOutline_IndentationPerLevel", "TestRenderAXOutline_TruncationAddsSuffix", "TestTrimAXTree_DiscardsIgnored", "TestTrimAXTree_CollapsesSameRoleSingleChild", "TestAxoPyRepr", "TestAxoParseNodes"] +test_file_path: "functions/browser/cdp_get_ax_outline_test.go" +file_path: "functions/browser/cdp_get_ax_outline.go" +params: + - name: c + desc: "Conexion CDP viva (*CDPConn) del pool, ya conectada al tab/target objetivo. No abre WebSocket nuevo: reusa la del pool. Nil devuelve error." + - name: frameID + desc: "frameId CDP del iframe a percibir. Cadena vacia ('') percibe el arbol completo de la pagina (depth -1). Con valor, obtiene el AX tree DENTRO de ese iframe." + - name: maxChars + desc: "Limite de caracteres del outline. >0 trunca y añade '\\n…[outline truncado]'. <=0 = sin limite." +output: "Outline accesible multi-linea: 2 espacios de indentacion por nivel, 'role \"name\"' por nodo, ' = '\\''value'\\''' en inputs, y marcador ' #ref=' en roles accionables. Cadena vacia si no hay nodos utiles." +--- + +## Ejemplo + +```go +// c es una *CDPConn viva del pool (la misma que usa el browser_mcp). +// Percibir la pagina entera, truncando a 8000 chars: +outline, err := CdpGetAXOutline(c, "", 8000) +if err != nil { + log.Fatal(err) +} +fmt.Println(outline) +// WebArea "Example Domain" +// heading "Example Domain" +// link "More information..." #ref=128 + +// Percibir DENTRO de un iframe concreto (frameId del frame tree): +inner, err := CdpGetAXOutline(c, "F1A2B3C4D5E6", 0) // 0 = sin limite +``` + +## Cuando usarla + +- Cuando necesites **percibir la pagina (o un iframe) como outline accionable** para que un LLM decida sobre `#ref` sin reventar el contexto. +- **Reemplaza el subprocess Python** `fn run cdp_perceive_outline`: es nativo Go, reusa la conexion CDP viva del pool y no arranca el venv en cada percepcion (mas rapido y sin dependencia de runtime `fn`/venv). +- Pasa `frameID` cuando el contenido objetivo vive dentro de un iframe; deja `frameID=""` para la pagina top-level. +- El `#ref` que devuelve (backendDOMNodeId) se pasa luego a `cdp_click_ref` / `cdp_type_ref` / `cdp_hover_ref`. + +## Gotchas + +- **Impura**: requiere un Chrome vivo con CDP accesible y el dominio `Accessibility` disponible. `Accessibility.enable` se envia siempre (idempotente). +- **Conexion nula** devuelve error inmediato; no intenta reconectar. +- **OOPIF cross-origin**: un iframe de distinto origen corre en un target (proceso) separado. Si `Accessibility.getFullAXTree` con ese `frameId` no devuelve nodos, probablemente necesites una `*CDPConn` adjunta al target del frame, no el `frameId` desde el target padre. +- **`#ref` = backendDOMNodeId**: estable mientras el nodo DOM viva, pero si la pagina re-renderiza ese subarbol el ref puede invalidarse. Percibe de nuevo tras una mutacion grande antes de actuar. +- El outline omite roles `none`/`presentation`/`ignored` y nodos `ignored=true`, y eleva sus hijos al nivel actual; un arbol con todo ignorado devuelve cadena vacia. +- Guard de profundidad 60 y guard de ciclo: arboles patologicos no cuelgan, pero pueden quedar recortados a partir de la profundidad 60. diff --git a/functions/browser/cdp_get_ax_outline_test.go b/functions/browser/cdp_get_ax_outline_test.go new file mode 100644 index 00000000..3bdcda56 --- /dev/null +++ b/functions/browser/cdp_get_ax_outline_test.go @@ -0,0 +1,279 @@ +package browser + +import ( + "strings" + "testing" +) + +// --- renderAXOutline: casos clave portados de render_ax_outline.py --- + +func TestRenderAXOutline_ActionableRoleCarriesRef(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "WebArea", name: "Page", childIDs: []string{"2"}}, + {nodeID: "2", backendDOMNodeID: "555", role: "button", name: "Submit"}, + } + got := renderAXOutline(nodes, 0) + want := "WebArea \"Page\"\n button \"Submit\" #ref=555" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestRenderAXOutline_NonActionableHasNoRef(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", backendDOMNodeID: "9", role: "heading", name: "Title"}, + } + got := renderAXOutline(nodes, 0) + if strings.Contains(got, "#ref") { + t.Errorf("rol no accionable no debe llevar #ref: %q", got) + } + if got != "heading \"Title\"" { + t.Errorf("got %q", got) + } +} + +func TestRenderAXOutline_InputShowsValue(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "form", childIDs: []string{"2"}}, + {nodeID: "2", backendDOMNodeID: "42", role: "textbox", name: "Email", value: "a@b.com"}, + } + got := renderAXOutline(nodes, 0) + want := "form\n textbox \"Email\" = 'a@b.com' #ref=42" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestRenderAXOutline_ValueWithSingleQuoteUsesDoubleQuote(t *testing.T) { + // Python repr: "it's" -> "it's" (comilla doble como delimitador). + nodes := []axNode{ + {nodeID: "1", backendDOMNodeID: "7", role: "textbox", value: "it's"}, + } + got := renderAXOutline(nodes, 0) + want := "textbox = \"it's\" #ref=7" + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestRenderAXOutline_SkipRoleElevatesChildren(t *testing.T) { + // El nodo 'none' se omite; su hijo button sube al nivel del padre (depth 1, + // no depth 2), porque el render del skip-node reusa el mismo depth. + nodes := []axNode{ + {nodeID: "1", role: "WebArea", name: "Root", childIDs: []string{"2"}}, + {nodeID: "2", role: "none", childIDs: []string{"3"}}, + {nodeID: "3", backendDOMNodeID: "30", role: "button", name: "Go"}, + } + got := renderAXOutline(nodes, 0) + want := "WebArea \"Root\"\n button \"Go\" #ref=30" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestRenderAXOutline_EmptyRoleElevatesChildren(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "", childIDs: []string{"2"}}, // sin role: se omite + {nodeID: "2", backendDOMNodeID: "20", role: "link", name: "Home"}, + } + got := renderAXOutline(nodes, 0) + // El nodo raiz sin role eleva su hijo a depth 0. + want := "link \"Home\" #ref=20" + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestRenderAXOutline_IndentationPerLevel(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "WebArea", name: "A", childIDs: []string{"2"}}, + {nodeID: "2", role: "group", name: "B", childIDs: []string{"3"}}, + {nodeID: "3", role: "group", name: "C"}, + } + got := renderAXOutline(nodes, 0) + want := "WebArea \"A\"\n group \"B\"\n group \"C\"" + if got != want { + t.Errorf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestRenderAXOutline_TruncationAddsSuffix(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "WebArea", name: "AAAAAAAAAAAAAAAAAAAA"}, + } + got := renderAXOutline(nodes, 10) + if !strings.HasSuffix(got, "\n…[outline truncado]") { + t.Errorf("falta sufijo de truncado: %q", got) + } + // El cuerpo truncado (sin sufijo) no debe exceder los 10 chars. + body := strings.TrimSuffix(got, "\n…[outline truncado]") + if len([]byte(body)) > 10 { + t.Errorf("cuerpo truncado mas largo que maxChars: %q (%d bytes)", body, len(body)) + } +} + +func TestRenderAXOutline_NoTruncationWhenUnderLimit(t *testing.T) { + nodes := []axNode{{nodeID: "1", role: "button", name: "X", backendDOMNodeID: "1"}} + got := renderAXOutline(nodes, 1000) + if strings.Contains(got, "truncado") { + t.Errorf("no debe truncar bajo el limite: %q", got) + } +} + +func TestRenderAXOutline_Empty(t *testing.T) { + if got := renderAXOutline(nil, 0); got != "" { + t.Errorf("nil -> %q, want vacio", got) + } +} + +func TestRenderAXOutline_RefFallsBackToNodeID(t *testing.T) { + // Sin backendDOMNodeId, el #ref usa el nodeId. + nodes := []axNode{ + {nodeID: "77", role: "button", name: "Fallback"}, + } + got := renderAXOutline(nodes, 0) + want := "button \"Fallback\" #ref=77" + if got != want { + t.Errorf("got %q want %q", got, want) + } +} + +func TestRenderAXOutline_CycleGuard(t *testing.T) { + // Ciclo 1 -> 2 -> 1: no debe colgar ni duplicar nodos. + nodes := []axNode{ + {nodeID: "1", role: "group", name: "A", childIDs: []string{"2"}}, + {nodeID: "2", role: "group", name: "B", childIDs: []string{"1"}}, + } + got := renderAXOutline(nodes, 0) + if strings.Count(got, "group \"A\"") != 1 { + t.Errorf("nodo A renderizado mas de una vez: %q", got) + } +} + +// --- trimAXTree: casos clave portados de trim_ax_tree.py --- + +func TestTrimAXTree_DiscardsIgnored(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "button", name: "Keep"}, + {nodeID: "2", role: "button", name: "Drop", ignored: true}, + } + got := trimAXTree(nodes) + if len(got) != 1 || got[0].nodeID != "1" { + t.Errorf("trim debe descartar ignored: %+v", got) + } +} + +func TestTrimAXTree_DiscardsEmptyGeneric(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "generic"}, // sin name ni childIds -> descartado + {nodeID: "2", role: "none"}, // idem + {nodeID: "3", role: "StaticText", name: ""}, // staticText vacio -> descartado + {nodeID: "4", role: "StaticText", name: "Hola"}, + } + got := trimAXTree(nodes) + if len(got) != 1 || got[0].nodeID != "4" { + t.Errorf("trim debe descartar generic/none/staticText vacios: %+v", got) + } +} + +func TestTrimAXTree_KeepsGenericWithChildren(t *testing.T) { + nodes := []axNode{ + {nodeID: "1", role: "generic", childIDs: []string{"2"}}, // tiene hijos -> se queda + {nodeID: "2", role: "button", name: "X"}, + } + got := trimAXTree(nodes) + if len(got) != 2 { + t.Errorf("generic con hijos debe conservarse: %+v", got) + } +} + +func TestTrimAXTree_CollapsesSameRoleSingleChild(t *testing.T) { + // list -> list (1 hijo, mismo role): se fusiona, el padre hereda los childIds. + nodes := []axNode{ + {nodeID: "1", role: "list", childIDs: []string{"2"}}, + {nodeID: "2", role: "list", childIDs: []string{"3"}}, + {nodeID: "3", role: "listitem", name: "item"}, + } + got := trimAXTree(nodes) + // Nodo 2 desaparece; nodo 1 debe apuntar ahora a 3. + var saw1, saw2 bool + var node1 axNode + for _, n := range got { + if n.nodeID == "1" { + saw1 = true + node1 = n + } + if n.nodeID == "2" { + saw2 = true + } + } + if !saw1 || saw2 { + t.Fatalf("colapso fallido: saw1=%v saw2=%v got=%+v", saw1, saw2, got) + } + if len(node1.childIDs) != 1 || node1.childIDs[0] != "3" { + t.Errorf("padre fusionado debe heredar childIds del hijo: %+v", node1.childIDs) + } +} + +func TestTrimAXTree_PreservesOrder(t *testing.T) { + nodes := []axNode{ + {nodeID: "3", role: "button", name: "C"}, + {nodeID: "1", role: "button", name: "A"}, + {nodeID: "2", role: "button", name: "B"}, + } + got := trimAXTree(nodes) + if len(got) != 3 || got[0].nodeID != "3" || got[1].nodeID != "1" || got[2].nodeID != "2" { + t.Errorf("orden original no preservado: %+v", got) + } +} + +func TestTrimAXTree_Empty(t *testing.T) { + if got := trimAXTree(nil); got != nil { + t.Errorf("nil -> %+v, want nil", got) + } +} + +// --- axoPyRepr: paridad con Python repr() --- + +func TestAxoPyRepr(t *testing.T) { + cases := []struct{ in, want string }{ + {"hola", "'hola'"}, + {"it's", "\"it's\""}, // tiene ', no " -> delimitador " + {"say \"hi\"", "'say \"hi\"'"}, // tiene " -> delimitador ' + {"both ' and \"", "'both \\' and \"'"}, // ambos -> ' con escape del ' + {"a\nb", "'a\\nb'"}, + {"back\\slash", "'back\\\\slash'"}, + } + for _, c := range cases { + if got := axoPyRepr(c.in); got != c.want { + t.Errorf("axoPyRepr(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// --- axoParseNodes: extraccion del map CDP (numeros como float64) --- + +func TestAxoParseNodes(t *testing.T) { + result := map[string]any{ + "nodes": []any{ + map[string]any{ + "nodeId": "1", + "backendDOMNodeId": float64(555), // CDP int llega como float64 + "ignored": false, + "role": map[string]any{"value": "button"}, + "name": map[string]any{"value": "Go"}, + "value": map[string]any{"value": "x"}, + "childIds": []any{"2", "3"}, + }, + }, + } + got := axoParseNodes(result) + if len(got) != 1 { + t.Fatalf("got %d nodos, want 1", len(got)) + } + n := got[0] + if n.nodeID != "1" || n.backendDOMNodeID != "555" || n.role != "button" || + n.name != "Go" || n.value != "x" || len(n.childIDs) != 2 { + t.Errorf("parse incorrecto: %+v", n) + } +} diff --git a/functions/browser/cdp_get_html.md b/functions/browser/cdp_get_html.md index f54ab736..4346d5d3 100644 --- a/functions/browser/cdp_get_html.md +++ b/functions/browser/cdp_get_html.md @@ -7,7 +7,7 @@ version: "1.0.0" purity: impure signature: "func CdpGetHTML(c *CDPConn) (string, error)" description: "Retorna el HTML completo de la pagina actual (document.documentElement.outerHTML) via Runtime.evaluate. Captura el DOM vivo post-JavaScript, no el HTML fuente original." -tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools] +tags: [chrome, cdp, browser, automation, html, dom, scraping, devtools, navegator] uses_functions: [cdp_connect_go_browser, cdp_evaluate_go_browser] uses_types: [] returns: [] @@ -35,6 +35,16 @@ html, err := CdpGetHTML(conn) // html contiene el DOM completo con todos los cambios JS aplicados ``` +## Cuando usarla + +Cuando necesites el HTML completo del DOM vivo (post-JavaScript) para parsear/extraer con un selector externo, guardar un snapshot fiel, o alimentar un parser HTML. Ideal para scraping de SPAs (React, Vue, Angular) donde el HTML fuente original está vacío. + +## Gotchas + +- **Devuelve el HTML COMPLETO sin límite, a propósito**: no trunca ni resume. En páginas complejas pueden ser cientos de KB. Esto es deliberado: su trabajo es dar el DOM íntegro para parsing fiel, no un resumen. +- **NO usar para alimentar un LLM directamente**: el HTML crudo quema tokens y trae ruido (scripts, estilos inline, atributos). Para contexto de modelo usa `cdp_get_text` (innerText, con `maxBytes` opcional) o `cdp_perceive_outline` (outline accesible con #refs accionables). Reserva `cdp_get_html` para parsing programático. +- **Es el DOM actual, no el HTML fuente**: incluye los cambios que el JavaScript haya aplicado hasta el instante de la llamada. Si la página sigue hidratando, espera con `cdp_wait_idle` antes. + ## Notas A diferencia de `Page.getResourceContent`, esta funcion captura el estado actual del DOM incluyendo modificaciones hechas por JavaScript. Ideal para scraping de SPAs (React, Vue, Angular). El HTML retornado puede ser muy largo para paginas complejas. diff --git a/functions/browser/cdp_get_text_in_frame.go b/functions/browser/cdp_get_text_in_frame.go new file mode 100644 index 00000000..9617535a --- /dev/null +++ b/functions/browser/cdp_get_text_in_frame.go @@ -0,0 +1,44 @@ +package browser + +import ( + "fmt" + "unicode/utf8" +) + +// CdpGetTextInFrame retorna el texto visible (innerText) del documento de un +// iframe especifico, componiendo sobre CdpEvalInFrame con un mundo aislado CDP. +// +// Lee document.body.innerText (cae a document.documentElement.innerText si no +// hay body), evitando parsear HTML crudo. Replica la politica de truncado de +// CdpGetText: si maxBytes > 0 trunca al limite dado con corte rune-safe y añade +// un sufijo con el total original en bytes; si maxBytes <= 0 no hay limite. +// +// Propaga los errores de CdpEvalInFrame (frame inexistente, contexto caducado) +// envueltos con %w. +func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error) { + if c == nil { + return "", fmt.Errorf("cdp get text in frame: conexion nula") + } + if frameID == "" { + return "", fmt.Errorf("cdp get text in frame: frameID vacio") + } + + const expr = `(document.body ? document.body.innerText : document.documentElement.innerText) || ""` + + text, err := CdpEvalInFrame(c, frameID, expr) + if err != nil { + return "", fmt.Errorf("cdp get text in frame: %w", err) + } + + if maxBytes > 0 && len(text) > maxBytes { + total := len(text) + // Corte rune-safe: retrocede hasta encontrar un rune valido completo. + cut := maxBytes + for cut > 0 && !utf8.RuneStart(text[cut]) { + cut-- + } + text = text[:cut] + fmt.Sprintf("\n…[truncado, total %d bytes]", total) + } + + return text, nil +} diff --git a/functions/browser/cdp_get_text_in_frame.md b/functions/browser/cdp_get_text_in_frame.md new file mode 100644 index 00000000..43b782c4 --- /dev/null +++ b/functions/browser/cdp_get_text_in_frame.md @@ -0,0 +1,73 @@ +--- +id: cdp_get_text_in_frame_go_browser +name: cdp_get_text_in_frame +kind: function +lang: go +domain: browser +purity: impure +version: 1.0.0 +tested: false +description: "Devuelve el texto visible (innerText) del documento de un iframe concreto componiendo sobre CdpEvalInFrame en un mundo aislado CDP, sin parsear HTML crudo. Trunca a maxBytes con corte rune-safe igual que CdpGetText." +tags: [browser, cdp, iframe, frame, text, navegator] +signature: "func CdpGetTextInFrame(c *CDPConn, frameID string, maxBytes int) (string, error)" +uses_functions: [cdp_eval_in_frame_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: error_go_core +imports: [] +file_path: "functions/browser/cdp_get_text_in_frame.go" +example: | + conn, _ := CdpConnect("localhost", 9222, "") + frames, _ := CdpListFrames(conn) + text, err := CdpGetTextInFrame(conn, frames[1].ID, 4096) + fmt.Println(text) // texto visible del primer iframe, truncado a 4096 bytes +params: + - name: c + desc: "Conexión CDP activa obtenida con CdpConnect." + - name: frameID + desc: "ID del frame cuyo texto visible se quiere leer; obtenido de CdpListFrames (campo CdpFrame.ID)." + - name: maxBytes + desc: "Límite de bytes del texto devuelto. Si maxBytes > 0 trunca con corte rune-safe y añade un sufijo con el total original; si maxBytes <= 0 no hay límite." +output: "String con el innerText visible del documento del iframe (document.body.innerText, o document.documentElement.innerText si no hay body), opcionalmente truncado a maxBytes; error si la conexión es nula, el frameID está vacío o la evaluación CDP del frame falla." +--- + +## Ejemplo + +```go +conn, err := CdpConnect("localhost", 9222, "") +if err != nil { + log.Fatal(err) +} +defer conn.Close() + +// 1. Listar frames para localizar el iframe deseado +frames, err := CdpListFrames(conn) +if err != nil { + log.Fatal(err) +} + +// 2. Leer el texto visible de cada iframe (saltando el frame raíz) +for _, f := range frames { + if f.ParentID == "" { // frame raíz, no es un iframe + continue + } + text, err := CdpGetTextInFrame(conn, f.ID, 4096) + if err != nil { + log.Printf("error en frame %s: %v", f.ID, err) + continue + } + fmt.Printf("=== iframe %s (%s) ===\n%s\n", f.ID, f.URL, text) +} +``` + +## Cuando usarla + +Cuando necesites leer los datos visibles dentro de un iframe sin parsear HTML crudo: extraer el contenido textual de un widget embebido, un panel de pago, un captcha de texto o cualquier documento dentro de un `