diff --git a/bash/functions/infra/launch_fleetclaude.md b/bash/functions/infra/launch_fleetclaude.md new file mode 100644 index 00000000..00641a99 --- /dev/null +++ b/bash/functions/infra/launch_fleetclaude.md @@ -0,0 +1,101 @@ +--- +name: launch_fleetclaude +kind: function +lang: bash +domain: infra +version: "1.1.0" +purity: impure +signature: "launch_fleetclaude [--cwd ] [--bin ] [--session ] [--cols ]" +description: "Entrypoint de FleetView: abre una ventana kitty con una sesion tmux (socket aislado -L fleet) de dos panes (TUI fleetview a la izquierda, claude --dangerously-skip-permissions a la derecha) para centralizar la flota de Claudes. Instala atajos alt+flechas/alt+enter/alt+n que controlan la TUI desde cualquier pane, y fija el ancho del sidebar con hooks." +tags: [claude-fleet, infra, kitty, tmux, claude, fleetview, launcher] +params: + - name: --cwd + desc: "Directorio de trabajo de ambos panes tmux. Opcional. Default: raiz del repo fn_registry, derivada dinamicamente via git rev-parse desde la ubicacion del script (sin hardcodear paths de usuario)." + - name: --bin + desc: "Ruta al binario de la TUI fleetview que corre en el pane izquierdo. Opcional. Default: /apps/fleetview/fleetview. Si no es ejecutable, el pane izquierdo muestra un mensaje de como compilarla y deja una shell viva." + - name: --session + desc: "Nombre de la sesion tmux a crear o reutilizar. Opcional. Default: fleet. La funcion es idempotente sobre este nombre." + - name: --cols + desc: "Ancho en columnas del pane izquierdo (la TUI). Opcional. Default: 40." +output: "Crea/reutiliza una sesion tmux detached con dos panes y lanza una ventana kitty 'FleetView' adjunta a ella, desacoplada del shell padre (setsid). Imprime el estado por stdout. Sin valor de retorno; exit 0 en exito." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "bash/functions/infra/launch_fleetclaude.sh" +--- + +## Ejemplo + +```bash +# Via fn run (resuelve por nombre o ID): +fn run launch_fleetclaude + +# Directo, con cwd explicito: +launch_fleetclaude --cwd ~/fn_registry + +# Sesion y ancho de pane personalizados: +launch_fleetclaude --session fleet --cols 50 +``` + +Tras invocarlo aparece una ventana kitty titulada `FleetView` con dos panes +lado a lado: a la izquierda la TUI `fleetview`, a la derecha una sesion de +`claude --dangerously-skip-permissions`. Volver a invocarlo NO duplica la +sesion: reusa la existente y solo abre otra kitty adjunta. + +## Cuando usarla + +Usala cuando quieras un unico punto de entrada a la flota de Claudes en vez de +N ventanas kitty sueltas: lanzas `fleetclaude` y tienes la TUI de control y un +Claude listo para trabajar en la misma ventana. Tipico al empezar la jornada o +al retomar el trabajo en el repo `fn_registry`. + +## Gotchas + +- **Idempotencia tmux**: si la sesion `` (default `fleet`) ya existe, + NO se recrea el layout; solo se abre una kitty nueva adjunta a la misma + sesion. Para empezar de cero: `tmux kill-session -t fleet` antes de invocar. +- **kitty detached (setsid)**: la ventana se lanza con `setsid ... &` para + sobrevivir al cierre de la terminal que la invoco. No bloquea al shell padre. +- **`exec` en los panes**: tanto la TUI como `claude` se lanzan con `exec`, asi + que al terminar el proceso el pane se cierra en vez de dejar una shell zombie + colgando. Excepcion: el fallback cuando `fleetview` no esta compilado deja una + shell interactiva a proposito (para que veas el mensaje y puedas compilar). +- **Requiere fleetview compilado**: el default `--bin` apunta a + `/apps/fleetview/fleetview`. Si ese binario no existe, el pane izquierdo + muestra `cd apps/fleetview && go build -o fleetview .` en lugar de fallar en + silencio. Compila la TUI antes para el flujo completo. +- **Socket tmux aislado (`-L fleet`)**: toda la sesion vive en un server tmux + propio, separado del tmux por defecto del usuario. Asi los atajos `bind -n` + NO afectan otras sesiones (ej. una sesion `mobile-1` del movil) y matar el + server fleet no toca nada mas: `tmux -L fleet kill-server`. +- **Atajos en el socket, NO en kitty.conf**: instala `bind -n` para + `alt+flechas` (mover el cursor de la TUI), `alt+enter` (conmutar al Claude + seleccionado) y `alt+n` (abrir Claude nuevo). Son bindings de tmux que + redirigen la tecla al pane de la TUI (`send-keys -t console.0`), asi funcionan + ESTES DONDE ESTES (incluido escribiendo en el pane de Claude). No modifican la + configuracion de kitty ni los atajos globales del escritorio. +- **Ancho del sidebar via hooks**: `client-resized` y `window-layout-changed` + re-fijan el pane 0 (TUI) a `--cols` columnas, porque el `attach` de kitty y el + conmutar de Claude redistribuyen el espacio. +- **Necesita kitty y tmux en el PATH**: aborta con codigo != 0 si falta alguno. + +## Capability growth log + +- v1.3.0 (2026-06-17) — renombrada de `launch_kittyclaude` a `launch_fleetclaude` + (comando `fleetclaude`). Atajos: `alt+0` (= alt+n, abrir Claude nuevo), `alt+k` + (kill con confirmacion), `alt+r` (picker de reanudar sesiones cerradas) y + `alt+flecha-izquierda` (volver atras desde el picker). Cierra la window al salir + el Claude (`remain-on-exit off`). +- v1.2.0 (2026-06-16) — ancho del sidebar por defecto 47 columnas; `ctrl+0` como + atajo alterno para abrir Claude nuevo; `mouse on` (clic/rueda enrutados a la + TUI) y `extended-keys on` (para que `ctrl+0` llegue distinguible por el + protocolo de teclado de kitty). +- v1.1.0 (2026-06-16) — socket tmux aislado `-L fleet`; instala atajos + `alt+flechas` / `alt+enter` / `alt+n` que controlan la TUI desde cualquier + pane; hooks que mantienen fijo el ancho del sidebar tras attach/conmutar. diff --git a/bash/functions/infra/launch_fleetclaude.sh b/bash/functions/infra/launch_fleetclaude.sh new file mode 100644 index 00000000..7b72a40e --- /dev/null +++ b/bash/functions/infra/launch_fleetclaude.sh @@ -0,0 +1,216 @@ +#!/usr/bin/env bash +# launch_fleetclaude — Entrypoint MVP de FleetView. +# +# Abre UNA ventana kitty corriendo una sesion tmux de dos panes: +# - pane izquierdo: la TUI 'fleetview' (la flota de Claudes centralizada). +# - pane derecho: 'claude --dangerously-skip-permissions'. +# +# Objetivo: dejar de tener N ventanas kitty dispersas y centralizar el control +# de los Claudes en una sola ventana. +# +# Funcion IMPURA: lanza procesos (tmux + kitty) con efectos secundarios. +# - Crea/reusa una sesion tmux detached llamada (idempotente). +# - Lanza una ventana kitty desacoplada del shell padre (setsid) para que +# sobreviva al cierre de la terminal que la invoco. +# - No toca atajos de teclado ni kitty.conf. +set -euo pipefail +IFS=$' \t\n' + +launch_fleetclaude() { + local cwd="" + local bin="" + local session="fleet" + local cols=52 + local T="tmux -L fleet" # socket tmux aislado: no toca el tmux normal del usuario + + # ----------------------------------------------------------------------- + # Parseo de argumentos + # ----------------------------------------------------------------------- + while [[ $# -gt 0 ]]; do + case "$1" in + --cwd) + shift + cwd="${1:-}" + ;; + --bin) + shift + bin="${1:-}" + ;; + --session) + shift + session="${1:-}" + ;; + --cols) + shift + cols="${1:-40}" + ;; + -h|--help) + cat <<'USAGE' +Uso: launch_fleetclaude [opciones] + +Abre una ventana kitty con una sesion tmux de dos panes: la TUI fleetview a la +izquierda y 'claude --dangerously-skip-permissions' a la derecha. + +Opciones: + --cwd Directorio de trabajo de los panes. + Default: raiz del repo fn_registry (derivada dinamicamente). + --bin Ruta al binario de la TUI fleetview. + Default: /apps/fleetview/fleetview + --session Nombre de la sesion tmux. Default: fleet. + --cols Ancho (columnas) del pane izquierdo. Default: 40. + -h, --help Muestra esta ayuda. + +Ejemplos: + launch_fleetclaude + launch_fleetclaude --cwd ~/fn_registry + launch_fleetclaude --session fleet --cols 50 +USAGE + return 0 + ;; + *) + echo "launch_fleetclaude: opcion desconocida: '$1' (usa -h)" >&2 + return 2 + ;; + esac + shift + done + + # ----------------------------------------------------------------------- + # Derivar la raiz del repo fn_registry dinamicamente (NO hardcodear paths + # de usuario). Estrategia: subir desde la ubicacion del script con + # 'git rev-parse --show-toplevel'; fallbacks razonables si no aplica. + # ----------------------------------------------------------------------- + local script_dir repo_root="" + script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + # El script vive en /bash/functions/infra/, asi que la raiz son 3 + # niveles arriba; pero preferimos git para robustez. + repo_root="$(git -C "$script_dir" rev-parse --show-toplevel 2>/dev/null || true)" + if [[ -z "$repo_root" ]]; then + # Fallback 1: navegacion relativa desde la ubicacion del script. + repo_root="$(cd "$script_dir/../../.." 2>/dev/null && pwd || true)" + fi + if [[ -z "$repo_root" ]]; then + # Fallback 2: variable de entorno del registry o el cwd actual. + repo_root="${FN_REGISTRY_ROOT:-$PWD}" + fi + + # Defaults derivados de la raiz del repo. + [[ -z "$cwd" ]] && cwd="$repo_root" + [[ -z "$bin" ]] && bin="$repo_root/apps/fleetview/fleetview" + + # Validar cwd: si no existe, caer al repo_root. + if [[ ! -d "$cwd" ]]; then + echo "launch_fleetclaude: --cwd '$cwd' no existe; usando '$repo_root'." >&2 + cwd="$repo_root" + fi + + # ----------------------------------------------------------------------- + # Comprobar herramientas necesarias. + # ----------------------------------------------------------------------- + if ! command -v tmux >/dev/null 2>&1; then + echo "launch_fleetclaude: tmux no esta instalado." >&2 + return 1 + fi + if ! command -v kitty >/dev/null 2>&1; then + echo "launch_fleetclaude: kitty no esta instalado." >&2 + return 1 + fi + + # ----------------------------------------------------------------------- + # Comando para el pane izquierdo: + # - Si el binario fleetview existe -> ejecutarlo (exec, sin shell colgado). + # - Si NO existe -> mensaje claro + shell interactiva (no falla en silencio). + # ----------------------------------------------------------------------- + local left_cmd + if [[ -x "$bin" ]]; then + left_cmd="exec $(printf '%q' "$bin")" + else + # Fallback claro: instruye como compilar la TUI y deja una shell viva. + left_cmd="echo 'fleetview no compilado: cd apps/fleetview && go build -o fleetview .'; exec \"\$SHELL\"" + fi + + # ----------------------------------------------------------------------- + # Montar la sesion tmux SOLO si no existe (idempotencia). Socket aislado $T. + # ----------------------------------------------------------------------- + if $T has-session -t "$session" 2>/dev/null; then + echo "launch_fleetclaude: la sesion tmux '$session' ya existe; reutilizandola." + else + echo "launch_fleetclaude: creando sesion tmux '$session' en '$cwd'." + + # Sesion detached con ventana 'console', pane 0 en el cwd objetivo. + $T new-session -d -s "$session" -n console -c "$cwd" + + # pane 0 (izquierda) = la TUI fleetview (o el fallback claro). + $T send-keys -t "$session":console.0 "$left_cmd" C-m + + # pane 1 (derecha) = claude, dividiendo horizontalmente (split lado a lado). + $T split-window -h -t "$session":console -c "$cwd" + $T send-keys -t "$session":console.1 "exec claude --dangerously-skip-permissions" C-m + + # Fijar el ancho del pane izquierdo en columnas. + $T resize-pane -t "$session":console.0 -x "$cols" + + # Foco inicial en el pane de claude (derecha). + $T select-pane -t "$session":console.1 + fi + + # ----------------------------------------------------------------------- + # Atajos globales (alt+*) en el socket aislado: redirigen la tecla al pane + # de la TUI (console.0) ESTES DONDE ESTES, para controlar la flota sin salir + # del pane de Claude. La TUI (fleetview) es quien interpreta Up/Down/Enter/n. + # `bind -n` = tabla root (sin prefijo). Idempotente: re-set en cada lanzamiento. + # ----------------------------------------------------------------------- + $T bind -n M-Up send-keys -t "$session":console.0 Up + $T bind -n M-Down send-keys -t "$session":console.0 Down + $T bind -n M-Enter send-keys -t "$session":console.0 Enter + $T bind -n M-n send-keys -t "$session":console.0 n + $T bind -n M-0 send-keys -t "$session":console.0 n + $T bind -n M-k send-keys -t "$session":console.0 k + $T bind -n M-r send-keys -t "$session":console.0 r + $T bind -n M-u send-keys -t "$session":console.0 u + $T bind -n M-h send-keys -t "$session":console.0 h + $T bind -n M-Left send-keys -t "$session":console.0 Escape + $T bind -n M-q send-keys -t "$session":console.0 Q + # Raton: enruta clicks/rueda al pane bajo el cursor; la TUI los interpreta. + $T set -g mouse on + # Al salir un Claude (exit / Ctrl-D / kill), cerrar su window en vez de + # dejarla muerta ("dead" pane) en la sesion. + $T set -g remain-on-exit off + + # Estetica neutra: sin el verde fosforo por defecto de tmux. Status bar gris y + # bordes de pane gris tenue, iguales en activo e inactivo (separacion simple, + # sin resaltado de enfoque). + $T set -g status-style "bg=colour236,fg=colour250" + $T set -g pane-border-style "fg=colour238" + $T set -g pane-active-border-style "fg=colour240" + + # Mantener el ancho del sidebar (pane 0) cuando kitty redimensiona la ventana + # tras el attach, o cuando se conmuta de Claude (window-linked / layout change). + $T set-hook -g client-resized "resize-pane -t $session:console.0 -x $cols" + $T set-hook -g window-layout-changed "resize-pane -t $session:console.0 -x $cols" + + # ----------------------------------------------------------------------- + # Lanzar kitty adjuntando la sesion, DESACOPLADA del shell padre con + # setsid, para que no muera al cerrar la terminal invocadora. + # (Mismo patron que reboot_all_claudes para relanzar terminales.) + # ----------------------------------------------------------------------- + # Adjuntar la sesion: + # - Si se invoca desde una terminal interactiva, convertir ESA terminal en + # el panel FleetView (exec reemplaza el proceso; al hacer detach vuelve la + # shell). Asi `fleetclaude` no abre otra ventana: usa la actual. + # - Si NO hay TTY (atajo de escritorio, cron, script), abrir una ventana + # kitty nueva desacoplada (setsid) como antes. + if [ -t 0 ] && [ -t 1 ]; then + exec tmux -L fleet attach -t "$session" + fi + setsid kitty --title "FleetView" -e tmux -L fleet attach -t "$session" /dev/null 2>&1 & + disown 2>/dev/null || true + + echo "launch_fleetclaude: ventana kitty 'FleetView' adjunta a la sesion tmux '$session'." + return 0 +} + +# Permitir ejecutar el archivo directamente (no solo como funcion sourced). +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + launch_fleetclaude "$@" +fi diff --git a/docs/capabilities/INDEX.md b/docs/capabilities/INDEX.md index 9814a2a4..0f5f45d4 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 | +| [claude-fleet](claude-fleet.md) | 5 | Orquestar la flota de procesos Claude Code vivos: panel TUI (fleetview) + comando fleetclaude que centraliza N Claudes en una ventana kitty/tmux (socket -L fleet), conmuta cual esta embebido (alt+flechas/enter/n) y los lista desde ~/.claude/sessions+goals | | [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 | | [hoppscotch](hoppscotch.md) | 7 | Operar Hoppscotch SELF-HOSTED (docker en selfhost/) via API GraphQL: login (magic link headless via mailpit), CRUD de requests (create/update/delete/list), set_environment (idempotente, resuelve secretos pass:). El agente crea/edita y el humano lo ve en vivo en su GUI (subscriptions). build es helper interno de serializacion. Modo .json local ELIMINADO | | [dav](dav.md) | 9 | Cliente CardDAV/CalDAV (Python, solo stdlib) para Xandikos: parte un .vcf/.ics export de Google en recursos individuales (split puro), extrae/sintetiza UID, sube por HTTP PUT con Basic auth, lista (PROPFIND) y descarga (GET) recursos. Dos pipelines de import (vcf->carddav, ics->caldav). Formaliza la migracion ad-hoc de contactos/calendario | @@ -52,7 +53,9 @@ Indice de grupos de capacidades del registry. Cada grupo agrupa >=3 funciones qu | [terminal-capture](terminal-capture.md) | 6 | Automatizar y capturar el texto de una CLI/TUI interactiva via PTY headless: spawn+input scripteado (one-shot y streaming), render del layout 2D (emulador VT), strip ANSI, delta por prefijo, y parseo de la TUI de claude a datos | | [claude-direct](claude-direct.md) | 3 | Hablar directamente con la API de Anthropic Messages usando el token OAuth de Claude Code (Claude Max): leer token, stream SSE, bucle agentico de tool-use | | [obsidian](obsidian.md) | 16 | CRUD headless de vaults y notas Obsidian como Markdown plano (frontmatter YAML + wikilinks): parse/format, read/create/update/delete/list/search notas, list/create vaults, slugify/embeds/resolve, render tabla Markdown + bloques sentinel gestionados. Sin app GUI | -| [duckdb](duckdb.md) | 5 | Operar bases DuckDB: open (Go), query read-only segura (Python, tipos JSON-safe), CSV->Parquet, dedup por hash, carga OHLCV. Base del patron BD-fuente-de-verdad + Obsidian-vista (app osint_db) | +| [duckdb](duckdb.md) | 10 | Operar bases DuckDB: open (Go), query/execute/upsert, introspeccion (list_tables, table_schema), CSV->Parquet, dedup, OHLCV, e ingesta desde Excel (excel_to_duckdb) + salida a Postgres (duckdb_to_postgres). Motor analitico del stack de datos Excel->DuckDB->Postgres->viz | +| [excel](excel.md) | 6 | CRUD de hojas Excel (.xlsx) con openpyxl: escribir multi-hoja, upsert no destructivo (preserva columnas manuales), leer a memoria, leer a markdown, graficos nativos (bar/line/pie/scatter), e ingesta a DuckDB. Round-trip de datos con humanos | +| [postgres](postgres.md) | 7 | CRUD de PostgreSQL via psycopg2 (dsn): connect (Go), query read-only, insert append-only, upsert idempotente, crear tabla inferida, introspeccion, aplicar .sql. Capa que sirve datos a Metabase/Grafana (que no hablan DuckDB nativo) | | [recon](recon.md) | 8 | Reconocimiento de red OSINT: whois, rdap, dns (dig), ping, traceroute, nmap por perfiles. Cada scan se archiva en OSINT (nota vault + tabla DuckDB network_scans) via el sink save_scan_to_osint o el pipeline one-shot recon_osint. Perfiles nmap pesados (full-tcp/vuln/udp-top) en segundo plano. No es framework de explotacion; solo hosts autorizados | | [osint-passive](osint-passive.md) | 8 | Recoleccion OSINT pasiva (fuentes publicas, no intrusiva): EXIF/PDF metadata, whois RDAP, DNS, subdominios crt.sh, guess emails, username enumeration, search dorks | | [osint-enrich](osint-enrich.md) | 3 | Orquestadores de enriquecimiento OSINT: componen osint-passive para aumentar datapoints de personas (emails/usernames/dorks), orgs (whois+dns+subdominios) y metadatos de attachments | diff --git a/docs/capabilities/claude-fleet.md b/docs/capabilities/claude-fleet.md new file mode 100644 index 00000000..d48a2ae6 --- /dev/null +++ b/docs/capabilities/claude-fleet.md @@ -0,0 +1,68 @@ +# Capability group: claude-fleet + +Operar la **flota de procesos Claude Code** vivos en la máquina como una sola +unidad: descubrirlos, listarlos en un panel TUI y centralizarlos en una ventana +kitty con tmux donde se conmuta cuál está embebido a la derecha. Reemplaza el +caos de N ventanas kitty dispersas por un único punto de entrada. + +Pieza visible: la app `fleetview` (TUI). Entrypoint: el comando `fleetclaude`. + +## Funciones + +| ID | Firma | Qué hace | +|---|---|---| +| `list_claude_fleet_go_infra` | `ListClaudeFleet() ([]ClaudeFleet, error)` | Escanea `~/.claude/sessions/*.json` + `goals/`, valida procesos vivos (anti-PID-reciclado), join por `sessionId` → lista tipada con status/objetivo/cwd/target. | +| `launch_fleetclaude_bash_infra` | `launch_fleetclaude [--cwd ] [--bin

] [--session ] [--cols ]` | Entrypoint: abre kitty con sesión tmux (socket aislado `-L fleet`) de dos panes (TUI izq + Claude der). Instala atajos `alt+*` e hijos del sidebar. | +| `tmux_new_claude_window_go_infra` | `TmuxNewClaudeWindow(socket, session, cwd string) (string, error)` | Crea una window tmux nueva con `claude --dangerously-skip-permissions`. Devuelve el `window_id`. | +| `tmux_swap_window_into_console_go_infra` | `TmuxSwapWindowIntoConsole(socket, session, windowID string) error` | Trae el Claude de `windowID` al pane derecho de `console` (junto a la TUI), parkea el anterior, re-fija el ancho del sidebar. | +| `tmux_map_claude_panes_go_infra` | `TmuxMapClaudePanes(socket string) (map[int]string, error)` | Mapa `claudePID → window_id` de los Claude que viven en la sesión (vía `list-panes` + descendencia `/proc`). Permite a la TUI saber cuáles son conmutables. | + +App relacionada: `fleetview_go_infra` (`apps/fleetview/`) — la TUI Bubble Tea que consume `list_claude_fleet` y orquesta los wrappers tmux. + +## Ejemplo canónico (end-to-end) + +```bash +# 1. Compilar la TUI una vez. +cd ~/fn_registry/apps/fleetview && go build -o fleetview . + +# 2. Abrir la flota (una ventana kitty: panel izq + Claude der). +fn run launch_fleetclaude + +# 3. Dentro de la ventana, desde CUALQUIER pane (incluido escribiendo en Claude): +# alt+↑/↓ mueve el cursor de la lista +# alt+enter conmuta el pane derecho al Claude seleccionado +# alt+n abre un Claude nuevo (window en fleet) y conmuta a él + +# Inspección headless de la flota sin abrir nada: +fn run list_claude_fleet | jq '.[] | {rename, status, goal}' +``` + +Bajo el capó de `alt+enter`/`alt+n`: tmux redirige la tecla al pane de la TUI +(`bind -n M-Enter send-keys -t console.0 Enter`); la TUI resuelve el Claude +seleccionado con `TmuxMapClaudePanes` y lo trae con `TmuxSwapWindowIntoConsole` +(o crea uno con `TmuxNewClaudeWindow`). + +## Fronteras (qué NO cubre) + +- **No gestiona Claudes remotos** (ej. los de una sesión tmux del móvil): se + listan como contexto pero no se embeben localmente (no son panes de fleet). +- **Adopción de Claudes sueltos pendiente**: un Claude vivo en otra ventana kitty + (fuera de fleet) se lista, pero `alt+enter` sobre él aún no lo trae — + requerirá relaunch `claude --resume ` dentro de fleet (patrón de + `reboot_all_claudes_bash_infra`). +- **No reinicia ni mata Claudes** (todavía): `resume`/`kill` desde el panel son + fase posterior. Para reiniciar toda la flota existe `reboot_all_claudes_bash_infra`. +- **Linux + kitty + tmux** únicamente (build tag `!windows`, usa `/proc`). + +## Prerequisitos + +- `kitty` y `tmux` en el PATH. La sesión vive en un server tmux aislado (`-L fleet`). +- La TUI `fleetview` compilada (`apps/fleetview/fleetview`). +- Claude Code ≥ 2.1.x (escribe `~/.claude/sessions/.json` con `status`). + +## Notas + +- Toda la sesión usa el socket `-L fleet`: los atajos `bind -n` no afectan al + tmux por defecto del usuario; `tmux -L fleet kill-server` lo limpia entero. +- `reboot_all_claudes_bash_infra` comparte la misma fuente de verdad + (`~/.claude/sessions/.json`) y es el complemento para reiniciar la flota. diff --git a/docs/capabilities/duckdb.md b/docs/capabilities/duckdb.md index a8ffa2e7..0f660520 100644 --- a/docs/capabilities/duckdb.md +++ b/docs/capabilities/duckdb.md @@ -15,6 +15,39 @@ Pieza central del patron **BD como fuente de verdad + Obsidian como vista** (pro | `csv_to_parquet_duckdb_py_core` | `csv_to_parquet_duckdb(csv_path, parquet_path, column_casts=None, overwrite=False) -> bool` | Convierte CSV -> Parquet con `read_csv_auto`. `column_casts` fuerza tipos por columna. No reescribe si el parquet existe y `overwrite=False`. | | `dedup_duckdb_table_by_hash_py_pipelines` | `dedup_duckdb_table_by_hash(duckdb_path, table, exclude_cols=None) -> dict` | Pipeline: anade columna `row_hash` (md5 de columnas de datos) idempotentemente y borra filas duplicadas conservando la primera insercion. | | `load_ohlcv_from_duckdb_go_finance` | `LoadOHLCVFromDuckDB(dbPath, query string) ([][]float64, error)` | Carga datos OHLCV ejecutando una query SQL sobre una base DuckDB (consumo desde apps Go de finanzas). | +| `duckdb_list_tables_py_infra` | `duckdb_list_tables(db_path) -> dict` | Introspección read-only: lista las tablas (`information_schema.tables`, schema main) ordenadas. Devuelve `{status, tables}`. | +| `duckdb_table_schema_py_infra` | `duckdb_table_schema(db_path, table) -> dict` | Introspección read-only: schema de una tabla (`DESCRIBE`). Devuelve `{status, table, columns:[{name,type}]}`. Útil para mapear tipos a otro motor (p.ej. PostgreSQL). | +| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | **Puente de entrada Excel→DuckDB**: ingiere una hoja `.xlsx` a una tabla con la extensión nativa `excel` de DuckDB. `replace`/`append`. Devuelve `{status, table, row_count}`. | +| `duckdb_to_postgres_py_pipelines` | `duckdb_to_postgres(duckdb_path, table, pg_dsn, pg_table=None, mode='replace', key_cols=None, batch_size=5000) -> dict` | **Puente de salida DuckDB→Postgres**: mapea tipos, crea la tabla y sincroniza filas. Desbloquea que Metabase/Grafana/Superset (que no hablan DuckDB) lean los datos. Devuelve `{status, pg_table, rows_synced, created}`. | + +## Puentes: Excel → DuckDB → Postgres → visualización + +DuckDB es el centro del stack de datos: el motor analítico embebido. Los datos entran desde Excel y salen hacia BI: + +```bash +cd /home/enmanuel/fn_registry +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from infra import excel_to_duckdb, duckdb_list_tables, duckdb_query_readonly +from pipelines.duckdb_to_postgres import duckdb_to_postgres + +# 1. Excel -> DuckDB (extensión nativa, sin pandas) +excel_to_duckdb("/tmp/ventas.xlsx", "/tmp/datos.duckdb", "ventas", sheet="ventas") +print(duckdb_list_tables("/tmp/datos.duckdb")) + +# 2. Analítica en DuckDB +print(duckdb_query_readonly("/tmp/datos.duckdb", + "SELECT categoria, SUM(importe) AS total FROM ventas GROUP BY 1")["rows"]) + +# 3. DuckDB -> Postgres (para que Metabase/Grafana lo lean) +# dsn = "postgresql://captacion:@localhost:5433/trends" +# duckdb_to_postgres("/tmp/datos.duckdb", "ventas", dsn, pg_table="ventas", mode="replace") +PYEOF +``` + +- **Evidence.dev** lee el `.duckdb` directamente (nativo) — no necesita el puente a Postgres. +- **Metabase / Grafana / Superset** no hablan DuckDB → usa `duckdb_to_postgres` y apunta la herramienta al Postgres espejo. ## Ejemplo canonico diff --git a/docs/capabilities/excel.md b/docs/capabilities/excel.md new file mode 100644 index 00000000..efbdfddf --- /dev/null +++ b/docs/capabilities/excel.md @@ -0,0 +1,64 @@ +# Capability: excel + +CRUD de hojas de cálculo Excel (`.xlsx`) desde el registry con openpyxl: escribir libros multi-hoja, actualizar una hoja sin destruir las demás (preservando columnas editadas a mano), leer a estructuras en memoria o a markdown, añadir gráficos nativos, e ingerir una hoja a DuckDB. + +Es el extremo Excel del **stack de datos** `Excel → DuckDB → Postgres → visualización`: el Excel sirve como entrada (lo que produce un humano o un export) y como entregable (un libro con gráficos que viaja por email/disco, sin servidor). El round-trip humano lo cubre `upsert_xlsx_sheet`, que conserva las columnas que las personas rellenan a mano mientras regenera las columnas calculadas. + +## Funciones + +| ID | Firma | Que hace | +|---|---|---| +| `write_xlsx_sheets_py_infra` | `write_xlsx_sheets(out_path, sheets, header_bold=True, autofit=True, freeze_header=True) -> str` | Escribe (o sobrescribe) un libro `.xlsx` multi-hoja desde un dict `{nombre_hoja: datos}`. Cada hoja acepta `list[list]` (primera fila = headers) o `{"headers": [...], "rows": [[...]]}`. Cabecera en negrita, auto-ancho, freeze de cabecera. Devuelve la ruta absoluta. | +| `upsert_xlsx_sheet_py_infra` | `upsert_xlsx_sheet(xlsx_path, sheet_name, records, columns, key_col="", preserve_cols=None, formulas=None, backup=True, ...) -> dict` | Actualiza NO destructivamente UNA hoja: reescribe solo `sheet_name` y conserva las demás. Antes de limpiar, lee por `key_col` las columnas de trabajo manual (`preserve_cols`) y las reescribe ganando sobre los datos nuevos. Cabecera estilizada, freeze, autofilter, fórmulas por columna, backup `.bak`. | +| `read_xlsx_py_infra` | `read_xlsx(path, sheet=None, max_rows=None, header=True) -> dict` | Lee un `.xlsx` a memoria (NO a markdown). Devuelve `{status, sheets: {nombre: {headers, rows}}}`. `sheet=None` lee todas. Tipos de celda: fechas→ISO, int/float, bool, None, fórmulas (valor calculado, `data_only=True`). Espejo en lectura de `write_xlsx_sheets`. | +| `excel_to_markdown_py_core` | `excel_to_markdown(path, max_rows_per_sheet=1000) -> str` | Convierte `.xlsx/.xls/.xlsm` a markdown, cada hoja como sección H2. Para inspección rápida / pegar en un prompt o nota. | +| `add_xlsx_chart_py_infra` | `add_xlsx_chart(xlsx_path, sheet_name, chart_type, data_range, cats_range=None, anchor='H2', title='', x_title='', y_title='') -> dict` | Añade un gráfico nativo (`bar`/`line`/`pie`/`scatter`) a una hoja EXISTENTE, refiriendo rangos de celdas ya escritos (notación Excel `'C1:C7'`). `anchor` = celda destino. La pieza para generar hojas Excel CON gráficos. | +| `excel_to_duckdb_py_infra` | `excel_to_duckdb(xlsx_path, duckdb_path, table, sheet=None, mode='replace') -> dict` | Ingesta una hoja del `.xlsx` a una tabla DuckDB con la extensión nativa `excel` de DuckDB. Puente Excel→DuckDB. También etiquetada en el grupo `duckdb`. | + +## Ejemplo canónico + +Escribir un libro, añadirle un gráfico y releerlo a memoria (verificado): + +```bash +cd /home/enmanuel/fn_registry +python/.venv/bin/python3 - <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from infra import write_xlsx_sheets, add_xlsx_chart, read_xlsx + +xlsx = "/tmp/ventas.xlsx" +write_xlsx_sheets(xlsx, {"ventas": [ + ["mes", "categoria", "importe"], + ["2026-01", "neumaticos", 12500.50], + ["2026-02", "neumaticos", 15800.75], + ["2026-03", "neumaticos", 18200.00], +]}) + +# Gráfico de barras del importe por mes, anclado en la celda G2 +add_xlsx_chart(xlsx, "ventas", "bar", data_range="C1:C4", cats_range="A2:A4", + anchor="G2", title="Importe por mes", y_title="EUR") + +rd = read_xlsx(xlsx, sheet="ventas") +print(rd["sheets"]["ventas"]["headers"], len(rd["sheets"]["ventas"]["rows"])) +PYEOF +``` + +## Gotchas del grupo + +- **openpyxl no evalúa fórmulas.** `read_xlsx` con `data_only=True` devuelve el valor **cacheado** por la última app que guardó el libro (Excel/LibreOffice). Un `.xlsx` con fórmulas escritas por openpyxl y nunca abierto en una hoja de cálculo devuelve `None` en esas celdas. +- **`add_xlsx_chart` exige libro y hoja existentes:** no crea el `.xlsx` ni escribe datos; los rangos deben apuntar a celdas ya escritas. Flujo: `write_xlsx_sheets` → `add_xlsx_chart`. +- **Rangos 1-indexed, notación Excel** (`'C1:C7'`). Si `data_range` incluye la fila de cabecera, el nombre de la serie sale de esa celda (`titles_from_data`). `scatter` usa `data_range` como Y y `cats_range` como X; `pie` ignora los títulos de eje. +- **Carga en memoria:** openpyxl carga el libro entero; para libros muy grandes considera ingerir a DuckDB (`excel_to_duckdb`) y consultar allí. +- **`upsert_xlsx_sheet` es la vía para datos editados por humanos:** si una persona rellena columnas a mano, pásalas en `preserve_cols` para que un re-volcado no las pise. + +## Fronteras + +- NO es una herramienta de BI ni de dashboards. Para visualización interactiva/compartida: Metabase, Evidence (sobre DuckDB) o gráficos embebidos con `add_xlsx_chart` para el caso "todo en el .xlsx". +- El análisis pesado (agregaciones, joins, histórico) NO se hace en Excel: ingiere a DuckDB con `excel_to_duckdb` y usa el grupo `duckdb`. +- NO cubre `.csv` de entrada con encodings legacy — eso es `safe_read_csv_fallback_py_core`. + +## Relación con otros grupos + +- `duckdb` — `excel_to_duckdb` es el puente de entrada; el motor analítico vive allí. +- `postgres` — la salida hacia BI pasa por `duckdb_to_postgres` (grupo `duckdb`/`postgres`). +- `metabase` — consume los datos una vez en Postgres. diff --git a/docs/capabilities/postgres.md b/docs/capabilities/postgres.md new file mode 100644 index 00000000..135d945c --- /dev/null +++ b/docs/capabilities/postgres.md @@ -0,0 +1,61 @@ +# Capability: postgres + +CRUD de PostgreSQL desde el registry. Las funciones Python (psycopg2) reciben un `dsn: str`, son impuras y devuelven un dict `{status:'ok'|'error', ...}` sin lanzar (mismo estilo que el grupo `duckdb`); la función Go (`postgres_open`) abre un `*sql.DB` desde parámetros individuales. + +Postgres es la **capa que sirve datos a las herramientas de BI** del stack (`Excel → DuckDB → Postgres → visualización`). Metabase, Grafana y Superset NO hablan DuckDB de forma nativa, pero todas hablan PostgreSQL: por eso el motor analítico de trabajo es DuckDB y, cuando un dashboard tiene que consumir esos datos, se sincronizan a Postgres con `duckdb_to_postgres` (grupo `duckdb`). + +## Funciones + +| ID | Firma | Que hace | +|---|---|---| +| `postgres_open_go_infra` | `PostgresOpen(host, port, user, password, dbname, sslmode) (*sql.DB, error)` | Conecta a PostgreSQL desde Go construyendo el DSN. `sslmode` por defecto `disable`. | +| `pg_query_py_infra` | `pg_query(dsn, sql, params=None, max_rows=10000) -> dict` | SELECT read-only (`SET TRANSACTION READ ONLY`) con `RealDictCursor`. Devuelve `{status, columns, rows, row_count, truncated}`. Normaliza tipos no JSON (date/datetime→ISO, Decimal→float, bytes→base64, UUID→str). Espejo de `duckdb_query_readonly`. Valores por `%s`. | +| `pg_insert_rows_py_infra` | `pg_insert_rows(dsn, table, rows, add_snapshot_date=True) -> int` | INSERT append-only en lote (`execute_values`). Deriva columnas de las claves. Opcional `snapshot_date = date.today()`. Retorna nº de filas. | +| `pg_upsert_py_infra` | `pg_upsert(dsn, table, rows, key_cols, update_cols=None) -> dict` | UPSERT idempotente `INSERT ... ON CONFLICT (key_cols) DO UPDATE SET col=EXCLUDED.col`. `update_cols` = ownership selectivo (las no listadas conservan su valor); `[]` = DO NOTHING. Devuelve `{status, inserted, updated}`. `key_cols` deben tener PK/UNIQUE. Espejo de `duckdb_upsert`. | +| `pg_create_table_from_rows_py_infra` | `pg_create_table_from_rows(dsn, table, rows, primary_key=None) -> dict` | `CREATE TABLE IF NOT EXISTS` infiriendo columnas y tipos desde los valores (bool→BOOLEAN, int→BIGINT, float→DOUBLE PRECISION, datetime→TIMESTAMP, date→DATE, resto→TEXT). Idempotente. Devuelve `{status, created, table, columns}`. | +| `pg_list_tables_py_infra` | `pg_list_tables(dsn, schema='public') -> dict` | Introspección read-only: tablas base con sus columnas vía `information_schema`. Devuelve `{status, schema, tables:[{name, columns:[{name,type,nullable}]}]}`. | +| `pg_apply_sql_py_infra` | `pg_apply_sql(dsn, sql_path) -> int` | Ejecuta un archivo `.sql` completo (multi-statement, una transacción). Para migraciones idempotentes (`IF NOT EXISTS`). | + +Relacionadas (otros grupos): `duckdb_to_postgres_py_pipelines` (sincroniza una tabla DuckDB a Postgres) e `init_metabase_go_infra` (despliega el stack Metabase + Postgres en Docker). + +## Ejemplo canónico + +Crear una tabla inferida, hacer upsert idempotente y consultar (DSN desde `pass`): + +```bash +cd /home/enmanuel/fn_registry +DSN="postgresql://captacion:$(pass captacion/postgres | head -1)@localhost:5433/trends" +python/.venv/bin/python3 - "$DSN" <<'PYEOF' +import sys +sys.path.insert(0, "python/functions") +from infra import pg_create_table_from_rows, pg_upsert, pg_query + +dsn = sys.argv[1] +rows = [{"mes": "2026-01", "total": 12500.5}, {"mes": "2026-02", "total": 15800.75}] + +pg_create_table_from_rows(dsn, "demo_kpi", rows, primary_key=["mes"]) +print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # inserted/updated +print(pg_upsert(dsn, "demo_kpi", rows, key_cols=["mes"])) # idempotente: 0 inserts +print(pg_query(dsn, "SELECT * FROM demo_kpi ORDER BY mes")["rows"]) +PYEOF +``` + +## Gotchas del grupo + +- **El DSN lleva credenciales — nunca hardcodear.** Resuélvelo desde `pass` (ej. `pass captacion/postgres`: L1 = password, resto `user/host/port/datadb`). No imprimas el DSN en logs. +- **`pg_query`/`pg_list_tables` son read-only por convención** (`SET TRANSACTION READ ONLY` + rollback), protegen la base pero NO son sandbox; los identificadores (tabla/schema) NO se parametrizan — los valores sí (`%s`). Las funciones validan identificadores con `^[A-Za-z_][A-Za-z0-9_]*$`. +- **`pg_upsert` cuenta insert vs update con el pseudo-columna `xmax`** (`RETURNING (xmax = 0)`). Fiable en el caso normal (single-writer, sin triggers raros). Con `update_cols=[]` (DO NOTHING) las filas en conflicto no se devuelven, así que solo se cuentan las nuevas. BEFORE-triggers / REPLICA IDENTITY pueden desviar el conteo. +- **`pg_create_table_from_rows` no reconcilia schema:** si la tabla ya existe, `columns` reporta los tipos inferidos de las filas, no los reales. Inferencia best-effort sin NUMERIC/escala — para dinero define el schema a mano con `pg_apply_sql`. +- **`pg_insert_rows` y `pg_apply_sql` lanzan en error** (no devuelven dict); envuélvelas si compones. + +## Fronteras + +- NO es el motor analítico del stack — ese es DuckDB (columnar, lee CSV/Parquet/Excel nativo). Postgres es el destino para BI. +- NO dibuja dashboards: eso es Metabase / Grafana / Evidence leyendo de Postgres. +- NO cubre PostGIS más allá de `osm2pgsql_ingest_py_infra` (geo, aparte). + +## Relación con otros grupos + +- `duckdb` — `duckdb_to_postgres` es el puente de entrada de datos a esta capa. +- `metabase` — registra la base con `metabase_add_database(engine='postgres', ...)` y consume las tablas. +- `excel` — el origen de los datos suele ser un `.xlsx` ingerido por `excel_to_duckdb`. diff --git a/functions/browser/cdp_click_ref.go b/functions/browser/cdp_click_ref.go index 2597bafb..2e100618 100644 --- a/functions/browser/cdp_click_ref.go +++ b/functions/browser/cdp_click_ref.go @@ -1,6 +1,15 @@ package browser -import "fmt" +import ( + "fmt" + "time" +) + +// refActionableTimeout es cuánto espera CdpClickRef/CdpHoverRef a que el elemento +// sea accionable (visible+stable+hit-test) antes de caer al cálculo de centro +// previo. Lo bastante para tragar animaciones/overlays transitorios sin penalizar +// el caso común (que converge en ~1 frame). +const refActionableTimeout = 2 * time.Second // refBoxCenter resuelve el centro (x,y) en coords de página de un nodo DOM por su // backendDOMNodeId, vía DOM.getBoxModel. El content quad son 8 floats (4 esquinas). @@ -37,6 +46,13 @@ func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error { if opts.Mode == "instant" { return clickRefViaJS(c, backendNodeID) } + // Preferir el punto validado por actionability (visible + stable + hit-test): + // evita clicks tragados por overlays/banners y elementos aún montándose o + // animándose. Si no converge dentro del timeout, se cae al cálculo de centro + // previo (sin regresión). + if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil { + return CdpClickXYHuman(c, x, y, opts) + } // 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) diff --git a/functions/browser/cdp_click_ref.md b/functions/browser/cdp_click_ref.md index 3f1da0ed..bc6e314f 100644 --- a/functions/browser/cdp_click_ref.md +++ b/functions/browser/cdp_click_ref.md @@ -8,7 +8,7 @@ purity: impure signature: "func CdpClickRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error" description: "Click humanizado (Bézier + jitter) sobre el elemento identificado por su #ref del AX outline. El #ref es el backendDOMNodeId estable del nodo DOM. Hace scroll al elemento si no está en viewport antes de calcular las coordenadas vía DOM.getBoxModel." tags: [cdp, browser, action, ref, humanized, navegator] -uses_functions: [cdp_click_xy_human_go_browser] +uses_functions: [cdp_click_xy_human_go_browser, cdp_wait_actionable_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/browser/cdp_collect_console.go b/functions/browser/cdp_collect_console.go new file mode 100644 index 00000000..4e816a20 --- /dev/null +++ b/functions/browser/cdp_collect_console.go @@ -0,0 +1,281 @@ +package browser + +import ( + "encoding/json" + "fmt" + "strings" + "sync" + "time" +) + +// ConsoleEntry es una entrada del log de consola/diagnostico capturada via CDP +// durante una ventana temporal. Type clasifica el origen: +// - "log"/"info"/"warn"/"error"/"debug" — Runtime.consoleAPICalled (console.*) +// - "exception" — Runtime.exceptionThrown (errores JS no capturados) +// - el level de Log.entryAdded ("verbose"/"info"/"warning"/"error") para +// avisos del propio navegador (network, security, deprecaciones...) +type ConsoleEntry struct { + Type string `json:"type"` // log|info|warn|warning|error|debug|exception|verbose + Text string `json:"text"` // mensaje legible (args concatenados / descripcion + stack) + URL string `json:"url"` // URL del script o recurso, si Chrome lo informa + Line int `json:"line"` // numero de linea (1-based), 0 si desconocido + Timestamp float64 `json:"timestamp"` // CDP timestamp (monotonic seconds) o wall time +} + +// consoleCollectDefaultMax es el tope de entradas por defecto cuando el caller +// pasa maxEntries <= 0. Acota la salida en paginas verbosas (setInterval ruidoso, +// SPA que loguea sin parar) para no devolver cientos de entradas y reventar el +// output del tool. +const consoleCollectDefaultMax = 200 + +// CdpCollectConsole habilita los dominios Runtime y Log en la conexion, se +// suscribe a los eventos de consola/excepcion/log del navegador y acumula todo +// lo que ocurra durante `durationMs` milisegundos, hasta un maximo de +// `maxEntries` entradas. Es un SNAPSHOT temporal: captura solo lo emitido dentro +// de la ventana, no el historico previo de la pagina. Si durationMs <= 0 usa +// 1500ms por defecto; si maxEntries <= 0 usa 200 por defecto. +// +// Dos defensas contra el backlog de una conexion del pool que lleva rato abierta +// con Runtime habilitado (donde Runtime.enable flushea consoleAPICalled rezagados +// con timestamps antiguos, y un setInterval verboso puede inundar): +// - Filtro por timestamp: se captura `startMs` (wall time, ms epoch) JUSTO antes +// de habilitar los dominios y solo se acumulan eventos cuyo timestamp sea >= +// startMs. Los eventos `consoleAPICalled`/`exceptionThrown`/`Log.entryAdded` +// traen `timestamp` en ms epoch, asi que los rezagados del flush (anteriores +// a startMs) se descartan. Eventos sin timestamp (0) se aceptan: no hay forma +// de fecharlos y casi siempre son nuevos. +// - Cap por cantidad: alcanzado `maxEntries` se dejan de acumular entradas, pero +// la funcion NO corta la ventana — sigue durmiendo hasta `durationMs` para no +// dejar los dominios CDP en estado raro (handlers a medio drenar). Las entradas +// posteriores al cap simplemente se descartan; el flag de truncamiento se +// refleja como una ConsoleEntry final de Type "_truncated". +// +// Eventos capturados y como se mapean a ConsoleEntry.Type: +// - Runtime.consoleAPICalled -> el `type` del evento (log/info/warning/error/...) +// - Runtime.exceptionThrown -> "exception" (texto = descripcion + stack) +// - Log.entryAdded -> el `level` del entry (warning/error del browser) +// +// Robusta ante silencio: si no llega ningun evento devuelve un slice vacio +// (no nil, no error). La conexion debe estar abierta; la funcion no la cierra. +func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error) { + if c == nil { + return nil, fmt.Errorf("cdp collect console: conexion nula") + } + if durationMs <= 0 { + durationMs = 1500 + } + if maxEntries <= 0 { + maxEntries = consoleCollectDefaultMax + } + + // startMs marca el inicio de la ventana en ms epoch (mismo dominio que el + // `timestamp` de los eventos CDP). Eventos anteriores = backlog -> se descartan. + startMs := float64(time.Now().UnixMilli()) + + var ( + mu sync.Mutex + entries = make([]ConsoleEntry, 0, 16) + truncated bool + ) + + // add intenta acumular una entrada respetando el filtro por timestamp y el cap. + // Devuelve sin hacer nada si la entrada es backlog o si ya se alcanzo el tope. + add := func(e ConsoleEntry) { + // Descartar backlog: eventos fechados antes del inicio de la ventana. + // Timestamp 0 (sin fecha) se acepta — no se puede clasificar como viejo. + if e.Timestamp != 0 && e.Timestamp < startMs { + return + } + mu.Lock() + if len(entries) >= maxEntries { + truncated = true + mu.Unlock() + return + } + entries = append(entries, e) + mu.Unlock() + } + + // Helpers para extraer campos de map[string]any sin pelearse con cast. + str := func(m map[string]any, k string) string { + if v, ok := m[k]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" + } + num := func(m map[string]any, k string) float64 { + if v, ok := m[k]; ok { + if f, ok := v.(float64); ok { + return f + } + } + return 0 + } + + // argToText convierte un RemoteObject de Runtime a una representacion legible. + // Para primitivas usa `value`; para objetos sin value cae a `description` o + // `unserializableValue`; ultimo recurso, el `type`. + argToText := func(arg map[string]any) string { + if v, ok := arg["value"]; ok && v != nil { + if s, ok := v.(string); ok { + return s + } + // objetos/arrays serializados por valor -> JSON real. + if b, err := json.Marshal(v); err == nil { + return string(b) + } + return fmt.Sprintf("%v", v) + } + if d := str(arg, "description"); d != "" { + return d + } + if u := str(arg, "unserializableValue"); u != "" { + return u + } + return str(arg, "type") + } + + // --- Runtime.consoleAPICalled: console.log / info / warn / error / ... --- + cancel1 := c.OnEvent("Runtime.consoleAPICalled", func(_ string, p map[string]any) { + entry := ConsoleEntry{ + Type: str(p, "type"), + Timestamp: num(p, "timestamp"), + } + // Concatenar los args a un texto legible separado por espacios. + if rawArgs, ok := p["args"].([]any); ok { + parts := make([]string, 0, len(rawArgs)) + for _, ra := range rawArgs { + if am, ok := ra.(map[string]any); ok { + parts = append(parts, argToText(am)) + } + } + entry.Text = strings.Join(parts, " ") + } + // stackTrace -> primer frame para URL/linea. + if st, ok := p["stackTrace"].(map[string]any); ok { + if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 { + if f0, ok := frames[0].(map[string]any); ok { + entry.URL = str(f0, "url") + // lineNumber es 0-based en CDP; +1 para ser 1-based legible. + if ln := int(num(f0, "lineNumber")); ln >= 0 { + entry.Line = ln + 1 + } + } + } + } + add(entry) + }) + defer cancel1() + + // --- Runtime.exceptionThrown: errores JS no capturados --- + cancel2 := c.OnEvent("Runtime.exceptionThrown", func(_ string, p map[string]any) { + entry := ConsoleEntry{ + Type: "exception", + Timestamp: num(p, "timestamp"), + } + ed, _ := p["exceptionDetails"].(map[string]any) + if ed != nil { + // Texto base de la excepcion. + text := str(ed, "text") + // Si hay un objeto de excepcion con descripcion (stack completo), preferirlo. + if exc, ok := ed["exception"].(map[string]any); ok { + if desc := str(exc, "description"); desc != "" { + if text != "" && !strings.Contains(desc, text) { + text = text + ": " + desc + } else { + text = desc + } + } + } + entry.Text = text + entry.URL = str(ed, "url") + // lineNumber 0-based -> 1-based. + if ln := int(num(ed, "lineNumber")); ln >= 0 { + entry.Line = ln + 1 + } + // stackTrace top frame como respaldo de URL/linea. + if entry.URL == "" { + if st, ok := ed["stackTrace"].(map[string]any); ok { + if frames, ok := st["callFrames"].([]any); ok && len(frames) > 0 { + if f0, ok := frames[0].(map[string]any); ok { + entry.URL = str(f0, "url") + if entry.Line == 0 { + if ln := int(num(f0, "lineNumber")); ln >= 0 { + entry.Line = ln + 1 + } + } + } + } + } + } + } + if entry.Text == "" { + entry.Text = "uncaught exception" + } + add(entry) + }) + defer cancel2() + + // --- Log.entryAdded: avisos del propio navegador (network, security...) --- + cancel3 := c.OnEvent("Log.entryAdded", func(_ string, p map[string]any) { + le, _ := p["entry"].(map[string]any) + if le == nil { + return + } + // Log.entryAdded reporta `timestamp` en segundos epoch (a diferencia de + // consoleAPICalled/exceptionThrown que lo dan en ms). Normalizar a ms para + // que el filtro por startMs compare en el mismo dominio. Heurística: si el + // valor parece segundos (varios órdenes por debajo de un ms epoch actual), + // multiplicar por 1000. + ts := num(le, "timestamp") + if ts > 0 && ts < startMs/100 { + ts *= 1000 + } + entry := ConsoleEntry{ + Type: str(le, "level"), // verbose|info|warning|error + Text: str(le, "text"), + URL: str(le, "url"), + Line: int(num(le, "lineNumber")), + Timestamp: ts, + } + add(entry) + }) + defer cancel3() + + // Habilitar dominios. Runtime.enable provoca un flush de consoleAPICalled + // rezagados; Log.enable abre el stream de avisos del navegador. + if _, err := c.sendCDP("Runtime.enable", nil); err != nil { + return nil, fmt.Errorf("cdp collect console: Runtime.enable: %w", err) + } + if _, err := c.sendCDP("Log.enable", nil); err != nil { + // Log.enable puede no estar disponible en algunos targets; no es fatal, + // seguimos capturando Runtime.*. Deshabilitar Runtime no hace falta. + _ = err + } + // No deshabilitamos Runtime al salir: otras funciones (ej. cdp_pick_element_js) + // dependen de consoleAPICalled. Solo cerramos Log que abrimos aqui. + defer c.sendCDP("Log.disable", nil) + + // Ventana de captura. No hacemos early-return al alcanzar el cap: seguimos + // durmiendo la ventana completa para no dejar los dominios CDP a medio drenar. + time.Sleep(time.Duration(durationMs) * time.Millisecond) + + mu.Lock() + out := make([]ConsoleEntry, len(entries)) + copy(out, entries) + wasTruncated := truncated + mu.Unlock() + + // Senal de truncamiento limpia: una entrada final que el caller puede detectar + // por Type == "_truncated" sin cambiar la forma del slice. + if wasTruncated { + out = append(out, ConsoleEntry{ + Type: "_truncated", + Text: fmt.Sprintf("output truncado al alcanzar maxEntries=%d; entradas posteriores descartadas", maxEntries), + Timestamp: float64(time.Now().UnixMilli()), + }) + } + return out, nil +} diff --git a/functions/browser/cdp_collect_console.md b/functions/browser/cdp_collect_console.md new file mode 100644 index 00000000..9def3804 --- /dev/null +++ b/functions/browser/cdp_collect_console.md @@ -0,0 +1,82 @@ +--- +name: cdp_collect_console +kind: function +lang: go +domain: browser +version: "1.1.0" +purity: impure +signature: "func CdpCollectConsole(c *CDPConn, durationMs int, maxEntries int) ([]ConsoleEntry, error)" +description: "Captura un snapshot temporal del log de consola y diagnostico de una pagina Chrome via CDP. Habilita los dominios Runtime y Log, se suscribe a Runtime.consoleAPICalled (console.log/info/warn/error con args concatenados), Runtime.exceptionThrown (errores JS no capturados, type=exception con descripcion + stack) y Log.entryAdded (avisos del propio navegador: network, security, deprecaciones) y acumula todo lo que ocurra durante durationMs ms (default 1500), hasta un maximo de maxEntries entradas (default 200). Devuelve un slice de ConsoleEntry (Type, Text, URL, Line, Timestamp). Es un snapshot de la ventana, no historico previo: filtra por timestamp para descartar el backlog de eventos que una conexion del pool acumulo antes de la llamada. Si se alcanza maxEntries deja de acumular pero no corta la ventana; anade una entrada final con Type=_truncated. Robusta ante silencio: devuelve slice vacio si no llega ningun evento." +tags: [chrome, cdp, browser, automation, console, devtools, debug, diagnostics, logs, errors, exceptions, flow-replay] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [encoding/json, fmt, strings, sync, time] +params: + - name: c + desc: "conexión CDP activa (*CDPConn) contra una pestaña Chrome con el target abierto" + - name: durationMs + desc: "ventana de captura en milisegundos; si <=0 usa 1500ms. Es el tiempo durante el cual se acumulan eventos de consola/excepcion/log antes de devolver. La función duerme la ventana completa aunque se alcance maxEntries antes" + - name: maxEntries + desc: "tope de entradas a acumular; si <=0 usa 200. Al alcanzarlo se descartan las entradas posteriores (no se corta la ventana) y se añade una entrada final con Type=_truncated. Acota la salida en páginas verbosas (setInterval ruidoso, SPA que loguea sin parar)" +output: "slice de ConsoleEntry (Type, Text, URL, Line, Timestamp) con todo lo emitido en la ventana (filtrado de backlog previo a la llamada y acotado a maxEntries); si se truncó, la última entrada tiene Type=_truncated; slice vacío (no nil, no error) si no hubo eventos; error solo si la conexión es nula o falla Runtime.enable" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_collect_console.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://example.com") + +// Captura todo lo que la pagina escriba en consola durante 2 segundos, +// hasta un maximo de 100 entradas (descarta el backlog previo de la conexion). +entries, err := CdpCollectConsole(conn, 2000, 100) +if err != nil { + log.Fatal(err) +} +for _, e := range entries { + if e.Type == "_truncated" { + fmt.Println("...", e.Text) // se alcanzo el cap de 100 entradas + continue + } + fmt.Printf("[%s] %s (%s:%d)\n", e.Type, e.Text, e.URL, e.Line) +} +// Ejemplo de salida: +// [error] Uncaught TypeError: x is not a function (https://example.com/app.js:42) +// [warning] Mixed Content: requested an insecure resource (https://example.com:0) +// [log] app initialized (https://example.com/app.js:5) + +// Cap por defecto (200): pasar maxEntries <= 0. +entries, _ = CdpCollectConsole(conn, 1500, 0) +``` + +## Cuando usarla + +Cuando necesitas ver qué errores, warnings o mensajes de consola produce una página justo después de navegar o tras disparar una acción (click, submit). Úsala para depurar por qué un flujo web falla en silencio (excepción JS no capturada, recurso bloqueado por CSP/mixed-content, error de red que solo aparece en consola), para validar que una SPA arrancó sin errores, o como paso de diagnóstico dentro de un flow-replay antes de dar por bueno un replay. Llámala envolviendo la acción que quieres observar: navega/interactúa y deja que la ventana de captura recoja lo que emita. + +## Gotchas + +- **Impura: requiere Chrome vivo.** Necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador. +- **Es un snapshot temporal, no histórico — y filtra el backlog.** Solo captura eventos emitidos DURANTE la ventana `durationMs`. La función captura `startMs` (wall time, ms epoch) justo antes de habilitar los dominios y descarta todo evento con `timestamp` anterior a ese inicio. Esto resuelve el problema real con conexiones del pool que llevan rato abiertas con `Runtime` ya habilitado: cuando `Runtime.enable` se reenvía, Chrome flushea `consoleAPICalled` rezagados con timestamps antiguos; esos backlog se descartan por el filtro. Sin el filtro, en una página verbosa o con un `setInterval` la función devolvía cientos de entradas históricas que reventaban el output. **Por qué `OnEvent` no basta:** los handlers de `OnEvent` solo reciben eventos que lleguen al `readLoop` DESPUÉS del registro, pero el flush de `Runtime.enable` llega justo después y arrastra mensajes viejos — de ahí el backlog. El filtro por timestamp es la defensa que lo separa. Si quieres capturar el arranque, conéctate y llama ANTES de navegar, o navega dentro de la ventana. +- **Eventos sin timestamp se aceptan.** Si un evento llega con `timestamp` 0 (sin fechar) no se puede clasificar como backlog, así que se acumula. En la práctica casi siempre son nuevos. +- **`Log.entryAdded` reporta en segundos, no ms.** A diferencia de `consoleAPICalled`/`exceptionThrown` (ms epoch), `Log.entryAdded` da `timestamp` en segundos epoch. La función lo normaliza a ms (heurística: si el valor es varios órdenes menor que un ms epoch actual, lo multiplica por 1000) para que el filtro por `startMs` compare en el mismo dominio. +- **Cap por cantidad (`maxEntries`).** Al alcanzar `maxEntries` entradas (default 200) la función deja de acumular y descarta las posteriores, pero **NO corta la ventana** — sigue durmiendo hasta `durationMs` para no dejar los dominios CDP a medio drenar (handlers a medias) ni el estado de la conexión raro. Si se truncó, la **última** entrada del slice tiene `Type == "_truncated"` y un `Text` con el cap alcanzado; el caller debe filtrarla o tratarla como señal, no como un log real. +- **Bloquea durante `durationMs`.** La función duerme la goroutine la ventana completa antes de devolver — no hay early-return aunque ya tengas eventos o se alcance el cap. Elige `durationMs` acorde a lo que esperas observar (1500ms default suele bastar para el load inicial). +- **`Type` mezcla tres taxonomías.** `consoleAPICalled` usa `log|info|warning|error|debug|...`; `exceptionThrown` siempre marca `exception`; `Log.entryAdded` usa el `level` del navegador (`verbose|info|warning|error`). Filtra por substring (`warn`, `error`) si quieres agrupar severidades; nota que console.warn produce `warning`, no `warn`. +- **`Line` es 1-based.** CDP reporta `lineNumber` 0-based; esta función suma 1 para que coincida con lo que muestran las DevTools. Los `Log.entryAdded` se dejan tal cual los da Chrome. +- **No deshabilita `Runtime` al salir.** Otras funciones del package (ej. `cdp_pick_element_js`) dependen de `Runtime.consoleAPICalled`; deshabilitarlo rompería sus handlers. Sí cierra el dominio `Log` que abre aquí. +- **`Log.enable` puede no estar disponible** en algunos targets (workers, ciertos contextos). Si falla, la función NO aborta: sigue capturando `Runtime.*` y solo pierde los avisos de `Log.entryAdded`. + +## Capability growth log + +- v1.1.0 (16/06/2026) — añade parámetro `maxEntries` (cap, default 200) + filtro de backlog por timestamp. Resuelve bug real: en conexiones del pool con `Runtime` ya habilitado, el flush de `Runtime.enable` arrastraba eventos históricos (cientos en páginas verbosas con `setInterval`) que reventaban el output. Ahora se descarta lo anterior a `startMs` y se acota la salida con señal `_truncated`. + +## Notas + +`ConsoleEntry` se define como tipo simple del package `browser` en el mismo `.go` (igual que `HarEntry`/`HarHeader` en `cdp_har_record.go`), no como tipo del registry — evita import circular y mantiene la firma autosuficiente. La acumulación usa un `sync.Mutex` porque los handlers de `OnEvent` corren en la goroutine del `readLoop` de `CDPConn`, concurrente con la goroutine que duerme la ventana. La conversión de args de `consoleAPICalled` serializa objetos/arrays a JSON real (no la repr `%v` de Go) para que datos estructurados sean parseables. diff --git a/functions/browser/cdp_fill.go b/functions/browser/cdp_fill.go new file mode 100644 index 00000000..b8825a96 --- /dev/null +++ b/functions/browser/cdp_fill.go @@ -0,0 +1,298 @@ +package browser + +import ( + "encoding/json" + "fmt" + "strings" +) + +// fillNodeInfo es el diagnostico que devuelve fillPrepare tras inspeccionar y +// preparar el nodo en el contexto JS de la pagina. Replica la logica de +// InjectedScript.fill de Playwright sin usar el "native value setter": para los +// campos de texto/contenteditable selecciona el contenido previo y deja que el +// motor inserte el valor con eventos confiables (ruta needsinput); para los +// inputs especiales fija el valor y dispara los eventos (ruta setvalue). +type fillNodeInfo struct { + // Route es "needsinput" (hay que insertar el valor via Input.insertText), + // "setvalue" (ya se fijo el valor + eventos, nada mas que hacer) o "" si hubo error. + Route string `json:"route"` + // Error describe por que el nodo no se puede rellenar (no editable, readonly, + // disabled, oculto, tipo no soportado). Vacio si todo OK. + Error string `json:"error"` +} + +// resolveObjectID resuelve un backendDOMNodeId a un Runtime objectId, para poder +// ejecutar JS con `this` apuntando a ese nodo concreto via Runtime.callFunctionOn. +func resolveObjectID(c *CDPConn, backendNodeID int) (string, error) { + res, err := c.sendCDP("DOM.resolveNode", map[string]any{"backendNodeId": backendNodeID}) + if err != nil { + return "", fmt.Errorf("resolveNode ref %d: %w", backendNodeID, err) + } + obj, _ := res["object"].(map[string]any) + objID, _ := obj["objectId"].(string) + if objID == "" { + return "", fmt.Errorf("sin objectId para ref %d", backendNodeID) + } + return objID, nil +} + +// callFunctionOnJSON ejecuta functionDeclaration con `this` = objectId, pasando +// args como argumentos posicionales, y deserializa el valor de retorno (por valor) +// en out. La funcion JS debe devolver un objeto serializable. +func callFunctionOnJSON(c *CDPConn, objectID, functionDeclaration string, args []any, out any) error { + callArgs := make([]any, len(args)) + for i, a := range args { + callArgs[i] = map[string]any{"value": a} + } + res, err := c.sendCDP("Runtime.callFunctionOn", map[string]any{ + "objectId": objectID, + "functionDeclaration": functionDeclaration, + "arguments": callArgs, + "returnByValue": true, + "awaitPromise": true, + }) + if err != nil { + return err + } + if exc, ok := res["exceptionDetails"]; ok && exc != nil { + excMap, _ := exc.(map[string]any) + text, _ := excMap["text"].(string) + return fmt.Errorf("excepcion JS: %s", text) + } + if out == nil { + return nil + } + resVal, ok := res["result"].(map[string]any) + if !ok { + return fmt.Errorf("resultado inesperado: %v", res) + } + b, err := json.Marshal(resVal["value"]) + if err != nil { + return fmt.Errorf("marshal valor de retorno: %w", err) + } + return json.Unmarshal(b, out) +} + +// fillPrepareJS es la funcion JS (con `this` = elemento) que valida editabilidad, +// detecta el tipo y prepara el nodo. Replica InjectedScript.fill de Playwright: +// NO usa el native value setter para text/textarea/contenteditable (selecciona el +// valor previo y devuelve "needsinput" para que Input.insertText, con eventos +// confiables del motor, haga que React/Vue reconcilien solos). Para inputs +// especiales fija el valor y dispara input/change con {bubbles, composed}. +// +// arg[0] = value (string). +const fillPrepareJS = `function(value){ + var el = this; + if (!el || el.nodeType !== 1) return {route:"", error:"el #ref no es un elemento"}; + // Visibilidad: rect con area + no display:none/visibility:hidden. + var rect = el.getBoundingClientRect(); + var style = el.ownerDocument.defaultView.getComputedStyle(el); + if (style.visibility === "hidden" || style.display === "none" || (rect.width === 0 && rect.height === 0)) + return {route:"", error:"elemento no visible"}; + var tag = el.nodeName.toLowerCase(); + if (tag === "input") { + var type = (el.type || "text").toLowerCase(); + if (el.disabled) return {route:"", error:"input deshabilitado"}; + if (el.readOnly) return {route:"", error:"input es readonly"}; + var kSetValue = {color:1, date:1, time:1, "datetime-local":1, month:1, range:1, week:1}; + var kTypeInto = {"":1, email:1, number:1, password:1, search:1, tel:1, text:1, url:1}; + if (!kTypeInto[type] && !kSetValue[type]) + return {route:"", error:"input de tipo '"+type+"' no se puede rellenar"}; + if (type === "number") { + value = value.trim(); + if (value !== "" && isNaN(Number(value))) + return {route:"", error:"no se puede escribir texto en input[type=number]"}; + } + if (type === "color") value = value.toLowerCase(); + if (kSetValue[type]) { + value = value.trim(); + el.focus(); + el.value = value; + if (el.value !== value) return {route:"", error:"valor malformado para input[type="+type+"]"}; + el.dispatchEvent(new Event("input", {bubbles:true, composed:true})); + el.dispatchEvent(new Event("change", {bubbles:true})); + return {route:"setvalue", error:""}; + } + // Ruta needsinput: seleccionar el valor previo para que insertText lo reemplace. + el.select(); + el.focus(); + return {route:"needsinput", error:""}; + } + if (tag === "textarea") { + if (el.disabled) return {route:"", error:"textarea deshabilitado"}; + if (el.readOnly) return {route:"", error:"textarea es readonly"}; + el.selectionStart = 0; + el.selectionEnd = el.value.length; + el.focus(); + return {route:"needsinput", error:""}; + } + if (el.isContentEditable) { + el.focus(); + var range = el.ownerDocument.createRange(); + range.selectNodeContents(el); + var sel = el.ownerDocument.defaultView.getSelection(); + if (sel) { sel.removeAllRanges(); sel.addRange(range); } + return {route:"needsinput", error:""}; + } + return {route:"", error:"el elemento no es input, textarea ni [contenteditable]"}; +}` + +// fillVerifyJS lee el valor actual del nodo (input.value/textarea.value o +// textContent de contenteditable) para verificar que el fill surtio efecto. +// arg[0] = expected (string). Devuelve {ok:bool, got:string, verifiable:bool}. +const fillVerifyJS = `function(expected){ + var el = this; + var tag = el.nodeName.toLowerCase(); + if (tag === "input" || tag === "textarea") { + var type = tag === "input" ? (el.type||"text").toLowerCase() : "text"; + var got = String(el.value); + var exp = expected; + if (type === "number" || type === "color" || type === "date" || type === "time" || + type === "datetime-local" || type === "month" || type === "range" || type === "week") { + exp = expected.trim(); + if (type === "color") exp = exp.toLowerCase(); + } + return {ok: got === exp, got: got, verifiable: true}; + } + // contenteditable: no verificable de forma fiable (el motor normaliza el HTML). + return {ok: true, got: String(el.textContent||""), verifiable: false}; +}` + +// CdpFill rellena un campo de texto controlado por frameworks (React/Vue) de +// forma robusta, estilo Playwright. backendNodeID es un backendDOMNodeId (el #ref +// del AX outline de page_perceive). +// +// Comportamiento (replica InjectedScript.fill): +// 1. Valida visible + enabled + editable (no readonly/disabled) en el contexto JS. +// 2. Enfoca el nodo. +// 3. Detecta el tipo: +// - text/textarea/email/search/url/tel/password/number/contenteditable: ruta +// "needsinput" — selecciona el valor previo y luego inserta value con +// Input.insertText (eventos input/beforeinput confiables del motor; React/Vue +// reconcilian solos). Con value=="" borra la seleccion (Delete) en vez de insertar. +// - color/date/time/datetime-local/month/range/week: ruta "setvalue" — fija +// el.value y dispara input{bubbles,composed} + change{bubbles}. +// 4. Verifica que el.value === value al final (casos verificables); si no, error. +// +// A diferencia del patron focus+type que concatena al valor existente, CdpFill +// reemplaza el contenido entero y es fiable con inputs controlados por frameworks. +func CdpFill(c *CDPConn, backendNodeID int, value string) error { + if c == nil { + return fmt.Errorf("cdp fill: conexion nula") + } + + objID, err := resolveObjectID(c, backendNodeID) + if err != nil { + return fmt.Errorf("cdp fill: %w", err) + } + + // Enfocar el nodo (idempotente; fillPrepareJS tambien enfoca, pero DOM.focus + // hace scroll-into-view y deja el activeElement listo para Input.insertText). + if _, err := c.sendCDP("DOM.focus", map[string]any{"backendNodeId": backendNodeID}); err != nil { + return fmt.Errorf("cdp fill: focus ref %d: %w", backendNodeID, err) + } + + // Validar + preparar el nodo (selecciona valor previo o fija value+eventos). + var info fillNodeInfo + if err := callFunctionOnJSON(c, objID, fillPrepareJS, []any{value}, &info); err != nil { + return fmt.Errorf("cdp fill: preparar ref %d: %w", backendNodeID, err) + } + if info.Error != "" { + return fmt.Errorf("cdp fill: ref %d no editable: %s", backendNodeID, info.Error) + } + + switch info.Route { + case "setvalue": + // El valor ya se fijo y se dispararon los eventos en fillPrepareJS. + case "needsinput": + if value == "" { + // Sin valor: borrar la seleccion (el valor previo ya esta seleccionado). + // Delete elimina la seleccion sin insertar nada. + del := map[string]any{"type": "keyDown", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46} + if _, err := c.sendCDP("Input.dispatchKeyEvent", del); err != nil { + return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err) + } + delUp := map[string]any{"type": "keyUp", "key": "Delete", "code": "Delete", "windowsVirtualKeyCode": 46} + if _, err := c.sendCDP("Input.dispatchKeyEvent", delUp); err != nil { + return fmt.Errorf("cdp fill: borrar ref %d: %w", backendNodeID, err) + } + } else { + // Insertar el valor (reemplaza la seleccion previa) en un round-trip. + // Input.insertText emite los eventos confiables que React/Vue necesitan. + if _, err := c.sendCDP("Input.insertText", map[string]any{"text": value}); err != nil { + return fmt.Errorf("cdp fill: insertText ref %d: %w", backendNodeID, err) + } + } + default: + return fmt.Errorf("cdp fill: ruta de preparacion desconocida %q para ref %d", info.Route, backendNodeID) + } + + // Verificar que el valor cuajo (solo casos verificables: input/textarea). + var ver struct { + OK bool `json:"ok"` + Got string `json:"got"` + Verifiable bool `json:"verifiable"` + } + if err := callFunctionOnJSON(c, objID, fillVerifyJS, []any{value}, &ver); err != nil { + // La verificacion en si fallo (nodo desaparecido, etc.): no enmascarar. + return fmt.Errorf("cdp fill: verificar ref %d: %w", backendNodeID, err) + } + if ver.Verifiable && !ver.OK { + return fmt.Errorf("cdp fill: verificacion fallida en ref %d: el campo quedo con %q, se esperaba %q", backendNodeID, ver.Got, value) + } + + return nil +} + +// CdpFillSelector resuelve un selector CSS a su backendDOMNodeId (via +// DOM.getDocument + DOM.querySelector + DOM.describeNode) y delega en CdpFill. +// Util cuando se tiene un selector estable en vez del #ref del AX outline. +func CdpFillSelector(c *CDPConn, selector string, value string) error { + if c == nil { + return fmt.Errorf("cdp fill selector: conexion nula") + } + if strings.TrimSpace(selector) == "" { + return fmt.Errorf("cdp fill selector: selector vacio") + } + + docRes, err := c.sendCDP("DOM.getDocument", map[string]any{"depth": 0}) + if err != nil { + return fmt.Errorf("cdp fill selector: DOM.getDocument: %w", err) + } + root, ok := docRes["root"].(map[string]any) + if !ok { + return fmt.Errorf("cdp fill selector: respuesta de DOM.getDocument sin root") + } + rootNodeID, ok := root["nodeId"].(float64) + if !ok { + return fmt.Errorf("cdp fill selector: DOM.getDocument sin nodeId raiz") + } + + qsRes, err := c.sendCDP("DOM.querySelector", map[string]any{ + "nodeId": int(rootNodeID), + "selector": selector, + }) + if err != nil { + return fmt.Errorf("cdp fill selector: DOM.querySelector %q: %w", selector, err) + } + nodeIDVal, ok := qsRes["nodeId"].(float64) + if !ok || int(nodeIDVal) == 0 { + return fmt.Errorf("cdp fill selector: el selector %q no coincide con ningun elemento", selector) + } + + // Resolver el nodeId a backendNodeId (CdpFill opera sobre backendDOMNodeId). + descRes, err := c.sendCDP("DOM.describeNode", map[string]any{"nodeId": int(nodeIDVal)}) + if err != nil { + return fmt.Errorf("cdp fill selector: DOM.describeNode %q: %w", selector, err) + } + node, ok := descRes["node"].(map[string]any) + if !ok { + return fmt.Errorf("cdp fill selector: DOM.describeNode %q sin node", selector) + } + backendID, ok := node["backendNodeId"].(float64) + if !ok || int(backendID) == 0 { + return fmt.Errorf("cdp fill selector: %q sin backendNodeId", selector) + } + + return CdpFill(c, int(backendID), value) +} diff --git a/functions/browser/cdp_fill.md b/functions/browser/cdp_fill.md new file mode 100644 index 00000000..4a17e742 --- /dev/null +++ b/functions/browser/cdp_fill.md @@ -0,0 +1,66 @@ +--- +name: cdp_fill +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpFill(c *CDPConn, backendNodeID int, value string) error" +description: "Rellena un campo de texto de forma robusta estilo Playwright, fiable con inputs controlados por frameworks (React/Vue). Valida visible+enabled+editable, enfoca el nodo, y según el tipo: para text/textarea/email/search/url/tel/password/number/contenteditable selecciona el valor previo y lo reemplaza con Input.insertText (eventos input/beforeinput confiables del motor — React/Vue reconcilian solos); para inputs especiales (color/date/time/range/week/month/datetime-local) fija el.value y dispara input{bubbles,composed}+change{bubbles}. Verifica que el.value===value al final. backendNodeID es el #ref del AX outline. Variante por selector: CdpFillSelector. Reemplaza el patrón frágil focus+type que concatena al valor existente." +tags: [cdp, browser, action, ref, fill, form, react, vue, navegator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +params: + - name: c + desc: "Conexión CDP activa al tab objetivo (*CDPConn)." + - name: backendNodeID + desc: "El #ref del AX outline = backendDOMNodeId estable del nodo DOM. Se obtiene de page_perceive / render_ax_outline." + - name: value + desc: "Valor a poner en el campo. Reemplaza el contenido entero (no concatena). value=='' borra el campo. Para input[type=number] debe ser numérico; para color se normaliza a minúsculas." +output: "nil si el campo quedó con el valor pedido; error si la conexión es nil, el nodo no es editable (readonly/disabled/oculto), el tipo de input no se puede rellenar, o la verificación final (el.value===value) falla." +file_path: "functions/browser/cdp_fill.go" +--- + +## Ejemplo + +```go +// Tras un page_perceive que devuelve un React con #ref=4521: +conn, _ := CdpConnect(9222) + +// Por #ref del AX outline (camino habitual del bucle percibir→actuar): +if err := CdpFill(conn, 4521, "ada@example.com"); err != nil { + log.Fatal(err) +} + +// Por selector CSS estable (resuelve a backendNodeID y delega en CdpFill): +if err := CdpFillSelector(conn, "input[name='email']", "ada@example.com"); err != nil { + log.Fatal(err) +} + +// Vaciar un campo: +_ = CdpFillSelector(conn, "#search", "") + +// Input especial (date): ruta setvalue + eventos input/change: +_ = CdpFillSelector(conn, "input[type='date']", "2026-06-16") +``` + +## Cuando usarla + +Cuando necesites rellenar inputs de formularios controlados por React/Vue/otros frameworks de forma fiable. Es el reemplazo del patrón `DOM.focus` + `CdpTypeText`/`CdpInsertText` que **concatena** al valor existente y a menudo deja el estado del framework desincronizado (el `value` del DOM cambia pero el estado de React no, o al revés). `CdpFill` selecciona y reemplaza el contenido entero y, al usar `Input.insertText` (no el native value setter), emite los eventos `input`/`beforeinput` confiables que hacen que el framework reconcilie su estado. Úsala para login, registro, búsquedas y cualquier campo donde el patrón focus+type falle o duplique texto. Para teclear carácter a carácter simulando un humano (sitios con detección por pulsación o autocompletes estrictos) sigue prefiriendo `CdpTypeRef` (camino human). + +## Gotchas + +- El `#ref` es un **backendDOMNodeId**, no el nodeId efímero del AX tree. Si la página recargó o navegó tras el snapshot, el ref puede estar muerto — re-percibir (`page_perceive`) antes de actuar. +- **contenteditable**: la ruta needsinput inserta el valor seleccionando todo el contenido, pero la verificación final **no es fiable** para contenteditable (el motor normaliza el HTML). Por eso para contenteditable `CdpFill` no falla por verificación; confía en que `Input.insertText` cuajó. Si necesitas garantía dura del contenido, léelo aparte con `CdpEvaluate`. +- **Inputs especiales** (color/date/time/datetime-local/month/range/week) van por la ruta setvalue: fijan `el.value` y disparan `input`{bubbles,composed}+`change`{bubbles}. Algunos frameworks que escuchan eventos de teclado en estos inputs pueden no reaccionar — es el mismo trade-off que hace Playwright. +- **input[type=number]**: el valor debe ser numérico (`isNaN` lo rechaza con error claro). Espacios se recortan. +- **Frameworks y el evento nativo**: la clave de la robustez es NO usar el "native value setter" (`Object.getOwnPropertyDescriptor(...).set`). React parchea el setter de `value` y se confunde si lo invocas a mano; `Input.insertText` del motor emite los eventos que React intercepta correctamente. Si una versión muy vieja de un framework custom no reacciona, cae a `CdpTypeRef` (char por char). +- **No hace scroll humanizado**: `DOM.focus` hace scroll-into-view del nodo, pero si el input está dentro de un contenedor con scroll propio y oculto, valida visible y puede fallar con "elemento no visible". En ese caso haz `CdpClickRef` (que hace `scrollIntoViewIfNeeded`) antes. +- **value==""** borra el campo enviando `Delete` sobre la selección previa (no `Input.insertText` con cadena vacía, que sería no-op). Esto dispara los eventos de borrado que el framework espera. diff --git a/functions/browser/cdp_find_by_role.go b/functions/browser/cdp_find_by_role.go new file mode 100644 index 00000000..1031a6ef --- /dev/null +++ b/functions/browser/cdp_find_by_role.go @@ -0,0 +1,191 @@ +package browser + +import ( + "fmt" + "regexp" + "strings" +) + +// CdpFindByRoleOpts configura el matching del accessible name de CdpFindByRole. +// Si Name == "", solo se filtra por role (cualquier name vale). +type CdpFindByRoleOpts struct { + // Name es el accessible name a matchear. Vacio = no filtra por name. + Name string + // Exact: true = el name normalizado debe ser igual al buscado. + // false (default) = el name normalizado contiene el buscado (substring). + Exact bool + // Regex: true = Name se interpreta como expresion regular (RE2 de Go). + // Tiene prioridad sobre Exact si ambos estan a true. + Regex bool + // CaseSensitive: false (default) = comparacion insensible a mayusculas. + // Para Regex, false añade el flag (?i) a la expresion. + CaseSensitive bool +} + +// normalizeWhiteSpace replica la regla de Playwright (utils/isomorphic/stringUtils.ts): +// elimina el zero-width space (U+200B) y el soft hyphen (U+00AD), recorta extremos y +// colapsa cualquier run de whitespace a un unico espacio. Es la normalizacion que +// Playwright aplica a ambos lados al comparar el accessible name (getByRole({name})), +// para que diferencias de whitespace/caracteres invisibles no rompan el match. +func normalizeWhiteSpace(s string) string { + // Strip zero-width space y soft hyphen. + s = strings.ReplaceAll(s, "​", "") + s = strings.ReplaceAll(s, "­", "") + // Colapsar runs de whitespace a un espacio. + s = whitespaceRun.ReplaceAllString(s, " ") + // Trim de extremos. + return strings.TrimSpace(s) +} + +// whitespaceRun matchea uno o mas caracteres de espacio en blanco. Equivale a +// `\s+` de la regex de normalizeWhiteSpace de Playwright. +var whitespaceRun = regexp.MustCompile(`\s+`) + +// CdpFindByRole localiza el primer elemento por su ROLE ARIA y, opcionalmente, su +// accessible name — el equivalente a getByRole de Playwright. Reutiliza el AX tree +// que ya pedimos para page_perceive (Accessibility.getFullAXTree) en vez de tocar el +// DOM/CSS, lo que la hace robusta a cambios de markup/estilos. +// +// Recorre los nodos del AX tree y matchea: +// - role: igualdad exacta del rol ARIA (ej "button", "link", "textbox"). +// - name (si opts.Name != ""): el accessible name del nodo contra opts.Name, con +// normalizeWhiteSpace aplicado a ambos lados (regla Playwright). Por defecto es +// substring; Exact => igualdad; Regex => expresion regular. Insensible a +// mayusculas salvo CaseSensitive. +// +// Retorna (ref, count, error): +// - ref: backendDOMNodeId del primer match — el mismo #ref que produce el outline +// de page_perceive y que consume CdpClickRef/CdpHoverRef. +// - count: numero total de nodos que matchean. count > 1 indica ambiguedad: el +// caller decide si refinar (Name mas especifico, Exact, etc.). +// - error: conexion nula, role vacio, regex invalida, fallo CDP, o 0 matches. +func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error) { + if c == nil { + return 0, 0, fmt.Errorf("cdp find by role: conexion nula") + } + if role == "" { + return 0, 0, fmt.Errorf("cdp find by role: role vacio") + } + + // Construir el matcher del name una sola vez (compila la regex si aplica). + matchName, err := buildNameMatcher(opts) + if err != nil { + return 0, 0, fmt.Errorf("cdp find by role: %w", err) + } + + // Accessibility.enable (idempotente, cacheado) antes de getFullAXTree. + if err := c.ensureAX(); err != nil { + return 0, 0, fmt.Errorf("cdp find by role: Accessibility.enable: %w", err) + } + + res, err := c.sendCDP("Accessibility.getFullAXTree", nil) + if err != nil { + return 0, 0, fmt.Errorf("cdp find by role: Accessibility.getFullAXTree: %w", err) + } + + nodes := axoParseNodes(res) + + firstRef := 0 + haveFirst := false + for _, n := range nodes { + if n.ignored { + continue + } + if n.role != role { + continue + } + if opts.Name != "" && !matchName(n.name) { + continue + } + count++ + if !haveFirst { + // axoRefID prefiere backendDOMNodeID; ese es el ref que consume CdpClickRef. + if id, ok := atoiRef(axoRefID(n)); ok { + firstRef = id + haveFirst = true + } + } + } + + if count == 0 { + if opts.Name != "" { + return 0, 0, fmt.Errorf("cdp find by role: no element with role %q and name %q", role, opts.Name) + } + return 0, 0, fmt.Errorf("cdp find by role: no element with role %q", role) + } + if !haveFirst { + // Hubo matches pero ninguno tenia un ref entero usable (backendDOMNodeId + // ausente y nodeId no numerico): no podemos devolver un #ref valido. + return 0, count, fmt.Errorf("cdp find by role: %d match(es) para role %q pero sin backendDOMNodeId usable", count, role) + } + return firstRef, count, nil +} + +// buildNameMatcher devuelve la funcion que decide si un accessible name candidato +// matchea opts.Name, normalizando ambos lados con normalizeWhiteSpace. Si Name == "" +// el matcher siempre es true (no se filtra por name). Compila la regex una vez. +func buildNameMatcher(opts CdpFindByRoleOpts) (func(candidate string) bool, error) { + if opts.Name == "" { + return func(string) bool { return true }, nil + } + + want := normalizeWhiteSpace(opts.Name) + + if opts.Regex { + pat := opts.Name + if !opts.CaseSensitive { + pat = "(?i)" + pat + } + re, err := regexp.Compile(pat) + if err != nil { + return nil, fmt.Errorf("regex invalida %q: %w", opts.Name, err) + } + return func(candidate string) bool { + return re.MatchString(normalizeWhiteSpace(candidate)) + }, nil + } + + if !opts.CaseSensitive { + want = strings.ToLower(want) + } + + return func(candidate string) bool { + got := normalizeWhiteSpace(candidate) + if !opts.CaseSensitive { + got = strings.ToLower(got) + } + if opts.Exact { + return got == want + } + return strings.Contains(got, want) + }, nil +} + +// atoiRef convierte el ref string (backendDOMNodeId, ya normalizado a entero-string +// por axoStr) a int. Devuelve (0, false) si no es un entero parseable. +func atoiRef(s string) (int, bool) { + if s == "" { + return 0, false + } + neg := false + i := 0 + if s[0] == '-' { + neg = true + i = 1 + if len(s) == 1 { + return 0, false + } + } + n := 0 + for ; i < len(s); i++ { + ch := s[i] + if ch < '0' || ch > '9' { + return 0, false + } + n = n*10 + int(ch-'0') + } + if neg { + n = -n + } + return n, true +} diff --git a/functions/browser/cdp_find_by_role.md b/functions/browser/cdp_find_by_role.md new file mode 100644 index 00000000..afcff1f3 --- /dev/null +++ b/functions/browser/cdp_find_by_role.md @@ -0,0 +1,82 @@ +--- +name: cdp_find_by_role +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpFindByRole(c *CDPConn, role string, opts CdpFindByRoleOpts) (ref int, count int, err error)" +description: "Localiza el primer elemento por su ROLE ARIA + accessible name (estilo getByRole de Playwright) reusando el AX tree (Accessibility.getFullAXTree). Devuelve el backendDOMNodeId (#ref) del primer match y el total de matches para detectar ambiguedad." +tags: [browser] +params: + - name: c + desc: "Conexion CDP viva (*CDPConn) del pool. nil => error." + - name: role + desc: "Rol ARIA exacto a matchear (ej 'button', 'link', 'textbox', 'checkbox')." + - name: opts + desc: "CdpFindByRoleOpts: Name (accessible name, vacio = no filtra), Exact (igualdad en vez de substring), Regex (Name como expresion regular RE2), CaseSensitive (default false)." +output: "(ref int, count int, err error): ref = backendDOMNodeId del primer match (#ref para CdpClickRef/CdpHoverRef); count = total de matches (>1 = ambiguo); err si conexion nula, role vacio, regex invalida, fallo CDP o 0 matches." +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [] +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_find_by_role.go" +--- + +## Ejemplo + +```go +c, _ := browser.CdpConnect(9333) // conexion CDP del pool +ref, count, err := browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{ + Name: "Aceptar", // substring del accessible name, case-insensitive +}) +if err != nil { + log.Fatal(err) // ej: no element with role "button" and name "Aceptar" +} +if count > 1 { + log.Printf("aviso: %d botones matchean 'Aceptar', usando el primero", count) +} +// ref es el mismo #ref que produce page_perceive: alimentarlo a CdpClickRef. +_ = browser.CdpClickRef(c, ref, browser.MouseHumanOpts{}) + +// Match exacto + case-sensitive: +ref, _, _ = browser.CdpFindByRole(c, "link", browser.CdpFindByRoleOpts{ + Name: "Iniciar sesion", Exact: true, CaseSensitive: true, +}) + +// Match por regex (ej "Eliminar 3 elementos" / "Eliminar 12 elementos"): +ref, _, _ = browser.CdpFindByRole(c, "button", browser.CdpFindByRoleOpts{ + Name: `^Eliminar \d+ elementos$`, Regex: true, +}) +``` + +## Cuando usarla + +Cuando necesites localizar un control de forma robusta a cambios de DOM/CSS: el rol +ARIA + accessible name sobreviven a refactors de markup y clases CSS que romperian un +selector `nth-of-type`. Es el patron primario que recomienda Playwright (getByRole) +para encontrar elementos accionables (botones, links, inputs). Combina el `ref` +devuelto directamente con `cdp_click_ref` / `cdp_hover_ref` para actuar sin pasar por +un selector fragil. Revisa `count` antes de actuar: si es >1 la busqueda es ambigua +y conviene refinar (Name mas especifico, Exact, o Regex anclada). + +## Gotchas + +- El `name` que se matchea es el **accessible name computado** por el motor de + accesibilidad de Chrome (deriva de aria-label, label asociado, contenido, alt, + title segun la spec ARIA), **no** el `innerText` del elemento. Si buscas por el + texto visible literal, usa `cdp_find_ref_by_text` en su lugar. +- `count > 1` => ambiguedad: se devuelve el primer match en orden del AX tree, que no + siempre es el visualmente primero ni el que quieres. Refina la busqueda. +- El `role` se compara por **igualdad exacta** del rol ARIA: "button" no matchea + "menuitem" aunque ambos sean clicables. Mira el outline de `page_perceive` / + `cdp_get_ax_outline` para ver el rol real que Chrome asigna a cada nodo. +- Nodos `ignored` del AX tree se descartan. Si el elemento esta oculto (aria-hidden, + display:none) puede no aparecer y dar 0 matches. +- El `ref` es un `backendDOMNodeId`: estable mientras el nodo viva, pero si el DOM + muta entre el find y el click el ref puede quedar obsoleto. diff --git a/functions/browser/cdp_hover_ref.go b/functions/browser/cdp_hover_ref.go index 2e28dcd0..ccfd82a1 100644 --- a/functions/browser/cdp_hover_ref.go +++ b/functions/browser/cdp_hover_ref.go @@ -9,6 +9,10 @@ func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error { if c == nil { return fmt.Errorf("cdp hover ref: conexión nil") } + // Preferir el punto validado por actionability; si no converge, caer al centro. + if x, y, err := CdpWaitActionable(c, backendNodeID, false, refActionableTimeout); err == nil { + return CdpMoveMouseHuman(c, x, y, opts) + } // 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) diff --git a/functions/browser/cdp_hover_ref.md b/functions/browser/cdp_hover_ref.md index e57f74d9..65f33584 100644 --- a/functions/browser/cdp_hover_ref.md +++ b/functions/browser/cdp_hover_ref.md @@ -8,7 +8,7 @@ purity: impure signature: "func CdpHoverRef(c *CDPConn, backendNodeID int, opts MouseHumanOpts) error" description: "Mueve el ratón con trayectoria humanizada (Bézier) sobre el elemento identificado por su #ref del AX outline. Útil para activar menús desplegables, tooltips y cualquier interacción que dependa de hover. El #ref es el backendDOMNodeId estable del nodo DOM." tags: [cdp, browser, action, ref, humanized, navegator] -uses_functions: [cdp_move_mouse_human_go_browser] +uses_functions: [cdp_move_mouse_human_go_browser, cdp_wait_actionable_go_browser] uses_types: [] returns: [] returns_optional: false diff --git a/functions/browser/cdp_print_pdf.go b/functions/browser/cdp_print_pdf.go new file mode 100644 index 00000000..aa29a573 --- /dev/null +++ b/functions/browser/cdp_print_pdf.go @@ -0,0 +1,77 @@ +package browser + +import ( + "encoding/base64" + "fmt" +) + +// CdpPrintPDFOpts configura la generacion del PDF via Page.printToPDF. +type CdpPrintPDFOpts struct { + // Landscape orienta la pagina en horizontal cuando es true (vertical por defecto). + Landscape bool + // PrintBackground incluye los graficos de fondo (colores e imagenes CSS) cuando es true. + PrintBackground bool + // Scale es el factor de escala del renderizado (1.0 = tamano natural). + // Si es <= 0 se usa 1.0. Chrome acepta el rango [0.1, 2]. + Scale float64 + // PaperWidthIn es el ancho del papel en pulgadas. 0 deja el default del navegador (8.5in). + PaperWidthIn float64 + // PaperHeightIn es el alto del papel en pulgadas. 0 deja el default del navegador (11in). + PaperHeightIn float64 +} + +// CdpPrintPDF genera un PDF de la pagina actual via el metodo CDP Page.printToPDF +// y devuelve los bytes del PDF ya decodificados, sin tocar el disco. +// +// Usa transferMode "ReturnAsBase64" (el default de CDP): Chrome devuelve el PDF +// completo como string base64 en el campo "data" de la respuesta, que esta +// funcion decodifica a []byte. Es robusto ante paginas grandes porque sendCDP +// espera la respuesta completa por el WebSocket antes de decodificar. +// +// Las opciones se traducen a los params de Page.printToPDF: Landscape, +// PrintBackground y Scale siempre se envian (con Scale forzado a 1.0 si opts pide +// <= 0). PaperWidthIn/PaperHeightIn solo se envian cuando son > 0, dejando el +// tamano de papel por defecto del navegador en caso contrario. +// +// Es la primitiva reutilizable de impresion a PDF: util para devolver el PDF al +// LLM como document content (bytes) o para que un caller lo persista a disco. +func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error) { + if c == nil { + return nil, fmt.Errorf("cdp print pdf: conexion nula") + } + + scale := opts.Scale + if scale <= 0 { + scale = 1.0 + } + + params := map[string]any{ + "transferMode": "ReturnAsBase64", + "landscape": opts.Landscape, + "printBackground": opts.PrintBackground, + "scale": scale, + } + if opts.PaperWidthIn > 0 { + params["paperWidth"] = opts.PaperWidthIn + } + if opts.PaperHeightIn > 0 { + params["paperHeight"] = opts.PaperHeightIn + } + + result, err := c.sendCDP("Page.printToPDF", params) + if err != nil { + return nil, fmt.Errorf("cdp print pdf: %w", err) + } + + dataStr, ok := result["data"].(string) + if !ok { + return nil, fmt.Errorf("cdp print pdf: campo data ausente en respuesta") + } + + pdfData, err := base64.StdEncoding.DecodeString(dataStr) + if err != nil { + return nil, fmt.Errorf("cdp print pdf: decodificar base64: %w", err) + } + + return pdfData, nil +} diff --git a/functions/browser/cdp_print_pdf.md b/functions/browser/cdp_print_pdf.md new file mode 100644 index 00000000..22841310 --- /dev/null +++ b/functions/browser/cdp_print_pdf.md @@ -0,0 +1,61 @@ +--- +name: cdp_print_pdf +kind: function +lang: go +domain: browser +version: "1.0.0" +purity: impure +signature: "func CdpPrintPDF(c *CDPConn, opts CdpPrintPDFOpts) ([]byte, error)" +description: "Genera un PDF de la pagina actual via el metodo CDP Page.printToPDF y devuelve los bytes ya decodificados, sin tocar el disco. Usa transferMode ReturnAsBase64 (Chrome devuelve el PDF como base64 en el campo data) y lo decodifica a []byte. Aplica las opciones a los params: Landscape, PrintBackground y Scale siempre (Scale forzado a 1.0 si opts pide <= 0); PaperWidthIn/PaperHeightIn solo cuando son > 0, dejando el tamano de papel por defecto del navegador en caso contrario. Robusto ante paginas grandes. Primitiva reutilizable para devolver el PDF al LLM como document content o persistirlo a disco." +tags: [chrome, cdp, browser, automation, pdf, print, printToPDF, devtools, document, navegator] +uses_functions: [] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [encoding/base64, fmt] +params: + - name: c + desc: "conexión CDP activa (*CDPConn) contra Chrome con el target abierto" + - name: opts + desc: "opciones de impresión (Landscape, PrintBackground, Scale, PaperWidthIn, PaperHeightIn en pulgadas)" +output: "bytes del PDF decodificados desde base64, o error si falla la generación o la decodificación" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_print_pdf.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://example.com") + +pdfData, err := CdpPrintPDF(conn, CdpPrintPDFOpts{ + Landscape: false, + PrintBackground: true, + Scale: 1.0, + PaperWidthIn: 8.27, // A4 + PaperHeightIn: 11.69, // A4 +}) +// pdfData: bytes del PDF listos para escribir a disco o devolver al LLM +// os.WriteFile("example.pdf", pdfData, 0644) +``` + +## Cuando usarla + +Cuando necesitas el PDF de la página actual en memoria: para devolverlo al LLM como document content (bytes), para archivar el render de una página (factura, informe, dashboard) o como primitiva sobre la que un caller compone la escritura a disco. Úsala tras `CdpNavigate` + espera de carga (`CdpWaitIdle`) para asegurar que el contenido está renderizado antes de imprimir. + +## Gotchas + +- **Impura: requiere Chrome vivo**: necesita una conexión CDP activa (`*CDPConn`) contra una instancia de Chrome con el target abierto. No funciona sin navegador. +- **Solo en modo headless completo de impresión**: `Page.printToPDF` funciona de forma fiable en Chrome headless. En modo headed (con UI), algunas builds de Chrome devuelven `PrintToPDF is not implemented`; si lo necesitas con UI, lanza Chrome con `--headless=new`. +- **Scale fuera de rango**: Chrome acepta `scale` en `[0.1, 2]`. Esta función fuerza `1.0` cuando `opts.Scale <= 0`, pero no recorta valores válidos fuera de rango — si pasas `5.0`, Chrome puede rechazar el comando con error. +- **Paper en pulgadas**: `PaperWidthIn`/`PaperHeightIn` son pulgadas (la unidad nativa de CDP), no mm. A4 ≈ 8.27 × 11.69 in, Letter = 8.5 × 11 in. `0` deja el default del navegador (Letter). +- **Contenido lazy-load / dinámico**: `printToPDF` captura el DOM en el instante de la llamada. Si la página carga contenido al hacer scroll o por JS diferido, espera a que termine (scroll + `CdpWaitIdle`) antes de imprimir. +- **PrintBackground apagado por defecto**: igual que el diálogo de impresión de Chrome, los fondos CSS (colores e imágenes) no salen salvo que pongas `PrintBackground: true`. + +## Notas + +Adición al dominio `browser` (estilo CDP del paquete): el `.go` vive junto a las demás funciones `cdp_*.go` en el mismo paquete `browser`. El struct `CdpPrintPDFOpts` se define en el mismo archivo. Chrome retorna el PDF como base64 (`transferMode: "ReturnAsBase64"`, el default de CDP); esta función lo decodifica a `[]byte` y lo devuelve sin escribir a disco — el caller decide el destino. Patrón gemelo de `CdpScreenshotBytes` para el caso de impresión a PDF. diff --git a/functions/browser/cdp_select_dropdown.go b/functions/browser/cdp_select_dropdown.go new file mode 100644 index 00000000..77b1098c --- /dev/null +++ b/functions/browser/cdp_select_dropdown.go @@ -0,0 +1,275 @@ +package browser + +import ( + "fmt" + "strings" + "time" +) + +// CdpDropdownOpts configura la seleccion en un desplegable custom (no nativo). +type CdpDropdownOpts struct { + // Exact: true = el texto de la opcion debe ser igual (tras normalizar) a + // optionText. false (default) = match por substring. La comparacion siempre + // es case-insensitive y sobre el texto normalizado (trim + colapsar espacios). + Exact bool + // TimeoutMs es el tope de espera (ms) para que el listbox monte/anime y la + // opcion aparezca visible. <=0 usa el default 3000. + TimeoutMs int + // OptionRole es el rol ARIA de las opciones a buscar ("option" por defecto). + // Usar "menuitem" para menus tipo dropdown-menu, "treeitem" para arboles, etc. + OptionRole string +} + +// CdpSelectDropdown selecciona una opcion en un DESPLEGABLE CUSTOM (combobox/listbox +// ARIA, react-select, MUI Select, headlessui, select2, ...) — esos en los que un +// nativo NO aplica. Replica el patron de Playwright (que no tiene API para custom dropdowns): click REAL en el trigger (mousedown, no element.click JS), espera la apertura por polling (aria-expanded=true O [role=listbox]/[role=menu] visible O opciones con rect>0), localiza la opcion por texto normalizado (substring o exacto, case-insensitive) y hace click REAL en su centro, con verificacion suave (aria-expanded vuelve a false o Enter como fallback). Reusa CdpEvaluate, CdpClickXYHuman y CdpPressKey." +tags: [browser, chrome, cdp, automation, dropdown, combobox, listbox, aria, select, react-select, mui, headlessui, devtools] +uses_functions: [cdp_evaluate_go_browser, cdp_click_xy_human_go_browser, cdp_press_key_go_browser] +uses_types: [] +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: [fmt, strings, time] +params: + - name: c + desc: "conexion CDP activa (*CDPConn)" + - name: triggerSelector + desc: "selector CSS del elemento que abre el desplegable (el boton/combobox sobre el que se hace click real)" + - name: optionText + desc: "texto visible de la opcion a elegir; se normaliza (trim + colapsar espacios) y se compara case-insensitive, por substring si opts.Exact=false o por igualdad si opts.Exact=true" + - name: opts + desc: "CdpDropdownOpts{Exact bool (igualdad vs substring, default substring); TimeoutMs int (espera apertura+opcion, default 3000); OptionRole string (rol ARIA de las opciones, default 'option' — usar 'menuitem' para menus, 'treeitem' para arboles)}" +output: "error si el trigger no existe, si el dropdown no abre dentro del timeout (\"el dropdown no abrio\"), o si la opcion no aparece (\"option %q not found in dropdown\"); nil si el click sobre la opcion se realizo (la verificacion de cierre es suave y no falla duro si queda ambigua)" +tested: false +tests: [] +test_file_path: "" +file_path: "functions/browser/cdp_select_dropdown.go" +--- + +## Ejemplo + +```go +conn, _ := CdpConnect(9222) +CdpNavigate(conn, "https://mui.com/material-ui/react-select/") + +// Combobox MUI: el trigger es el div con role=combobox; el listbox monta y +// anima al abrir. CdpSelectDropdown clica el trigger, espera a que el listbox +// este visible y entonces clica la opcion "Twenty". +err := CdpSelectDropdown(conn, "[role=combobox]", "Twenty", CdpDropdownOpts{}) +if err != nil { + log.Fatal(err) +} + +// react-select / headlessui: trigger por clase + match exacto + timeout amplio +// para listas que tardan en montar. +err = CdpSelectDropdown(conn, ".select__control", "España", CdpDropdownOpts{ + Exact: true, + TimeoutMs: 6000, +}) + +// Menu tipo dropdown-menu (no listbox): las opciones son role=menuitem. +err = CdpSelectDropdown(conn, "#user-menu-btn", "Cerrar sesion", CdpDropdownOpts{ + OptionRole: "menuitem", +}) +``` + +## Cuando usarla + +Usala cuando el desplegable NO es un `` nativo de HTML usa `CdpSelectOption` (setea +`select.value` + dispara `input`/`change`), que es mas robusto y directo para ese +caso. + +## Gotchas + +- **Click real, no element.click()**: muchos dropdowns custom escuchan `mousedown` + (no `click`), por eso esta funcion despacha eventos de raton reales sobre el + centro del bbox. Solo cae a `element.click()` JS si el nodo no tiene geometria. +- **Animaciones de apertura**: el fallo nº1 reportado en Playwright es clicar la + opcion ANTES de que el listbox monte/anime. Por eso hay polling de apertura + (`dropdownWaitOpen`) que no avanza hasta que hay opciones visibles. Si tu + dropdown anima muy lento, sube `TimeoutMs`. +- **Listas virtualizadas** (react-window, virtuoso): solo renderizan las opciones + en viewport. Si la opcion buscada esta fuera del scroll inicial, puede que nunca + se monte y la funcion devuelva "not found" aunque exista. Mitigacion: escribe en + el combobox para filtrar (`CdpTypeText`) antes de llamar a esta funcion, o haz + scroll dentro del listbox primero. +- **Trigger vs contenedor**: `triggerSelector` debe apuntar al elemento que ABRE el + menu (el boton/combobox), no al `[role=listbox]` (que no existe hasta abrir). +- **Match de texto**: normaliza espacios y es case-insensitive; por defecto es + substring (`Exact=false`). Si varias opciones comparten substring, elige la + primera visible en orden de documento — usa `Exact=true` para desambiguar. +- **OptionRole**: por defecto `option` (`[role=option]`). Para menus de acciones usa + `menuitem`; para arboles `treeitem`. La deteccion de apertura tambien considera + `[role=menu]` y `li[role]` para cubrir patrones comunes. +- **Verificacion suave**: tras clicar, si el dropdown sigue abierto la funcion pulsa + `Enter` como fallback y devuelve `nil`. No falla duro si la seleccion no se puede + confirmar inequivocamente pero el click se hizo — comprueba el estado resultante + (texto del trigger, valor del formulario) si necesitas certeza. +- **iframes**: opera en el documento principal (via `CdpEvaluate`). Para un dropdown + dentro de un iframe necesitarias el contexto del frame (no cubierto aqui). diff --git a/functions/browser/cdp_select_option.go b/functions/browser/cdp_select_option.go new file mode 100644 index 00000000..ebb402eb --- /dev/null +++ b/functions/browser/cdp_select_option.go @@ -0,0 +1,153 @@ +package browser + +import ( + "fmt" + "strings" +) + +// CdpSelectOption selecciona una

` + JS + (react-select, headlessui, Radix, etc.) NO son `` soportado:** setea `option.selected = true` sobre la option + encontrada sin tocar el resto de selecciones. En un ``) y + luego `change` con `{bubbles:true}`, en ese orden. Hace `focus()` del select antes. +- No hace scroll ni verifica visibilidad/enabled: opera sobre el DOM directamente. + Si el `` (error claro si no, apuntando a dropdowns + custom), sigue `