docs: issues técnicas para nuevas funcionalidades

Agrega 19 issues técnicas documentando funcionalidades implementadas y pendientes.

Issues completadas (movidas a dev/issues/completed/):
- 001-conversor-web-markdown.md
- 002-accessibility-tree.md
- 003-gestion-cookies-perfil.md
- 004-gestion-extensiones-chrome.md
- 005-eliminar-timeouts-innecesarios.md

Issues implementadas:
- 006-manejo-tabs-ventanas.md
- 016-manejo-iframes.md
- 017-actions-api.md
- 018-file-uploads.md
- 019-expected-conditions-mejoradas.md

Issues pendientes (media prioridad):
- 007-alert-prompt-confirm-handling.md
- 008-screenshot-elementos-especificos.md
- 009-pdf-generation.md
- 010-device-emulation-completo.md
- 011-downloads-handling.md

Issues pendientes (baja prioridad / avanzado):
- 012-browser-contexts-multi-sesion.md
- 013-video-recording.md
- 014-network-mocking-avanzado.md
- 015-geolocation-permissions.md

Incluye también dev/NUEVAS_FUNCIONALIDADES.md con resumen completo.

Directorio: dev/
This commit is contained in:
Developer
2026-03-25 00:49:06 +01:00
parent 7d5339acad
commit c165f2f788
20 changed files with 3512 additions and 0 deletions
@@ -0,0 +1,65 @@
# Issue #001: Conversor de página web a markdown
**Tipo**: Enhancement
**Prioridad**: Media
**Estado**: Pendiente
## Descripción
Implementar utilidad para convertir el contenido HTML de una página web a formato Markdown.
## Funcionalidad deseada
- Convertir títulos (h1-h6) a markdown (#, ##, ###, etc.)
- Convertir enlaces a formato `[texto](url)`
- Convertir imágenes a formato `![alt](src)`
- Convertir listas (ol, ul) a markdown
- Convertir tablas a markdown
- Mantener estructura de párrafos
- Extraer texto limpio sin CSS/JS inline
- Opción para incluir/excluir imágenes
- Manejar código y bloques de código (pre, code)
- Preservar énfasis (bold, italic)
## Implementación técnica
### Archivo sugerido
`pkg/browser/markdown.go`
### API propuesta
```go
// ToMarkdown convierte el contenido de la página actual a Markdown
func (b *Browser) ToMarkdown(ctx context.Context, opts *MarkdownOptions) (string, error)
type MarkdownOptions struct {
Selector string // Selector CSS opcional para convertir solo una parte
IncludeImages bool // Incluir imágenes en el output
IncludeLinks bool // Incluir enlaces
BaseURL string // URL base para enlaces relativos
}
```
### Estrategia
1. Obtener HTML con `GetHTML()`
2. Parsear HTML usando `golang.org/x/net/html`
3. Convertir nodos recursivamente a markdown
4. Alternativamente, ejecutar JS en el navegador con biblioteca turndown
### Librerías potenciales
- `github.com/JohannesKaufmann/html-to-markdown` - Conversor Go nativo
- O ejecutar `turndown.js` vía `Evaluate()` para mayor fidelidad
## Casos de uso
- Extraer contenido de artículos de blog
- Scraping de documentación
- Generar datasets para LLMs
- Archivar contenido web
## Referencias
- Turndown: https://github.com/mixmark-io/turndown
- html-to-markdown Go: https://github.com/JohannesKaufmann/html-to-markdown
@@ -0,0 +1,88 @@
# Issue #002: Recuperación de Accessibility Tree
**Tipo**: Enhancement
**Prioridad**: Alta
**Estado**: Pendiente
## Descripción
Implementar método para obtener el árbol de accesibilidad (Accessibility Tree) de la página usando Chrome DevTools Protocol.
## Funcionalidad deseada
- Obtener accessibility tree completo vía CDP
- Listar roles ARIA de elementos (button, link, heading, etc.)
- Obtener nombres accesibles de elementos
- Extraer propiedades de accesibilidad
- Útil para que LLMs entiendan estructura semántica de página
- Formato JSON estructurado y fácil de parsear
- Opción para filtrar por tipos de nodos
## Implementación técnica
### Archivo sugerido
`pkg/browser/accessibility.go`
### CDP Domain
`Accessibility.getFullAXTree` - https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/
### API propuesta
```go
// GetAccessibilityTree obtiene el árbol de accesibilidad de la página
func (b *Browser) GetAccessibilityTree(ctx context.Context, opts *AccessibilityOptions) (*AXTree, error)
type AccessibilityOptions struct {
Depth int // Profundidad máxima del árbol (0 = ilimitado)
FilterRoles []string // Roles a incluir (ej: ["button", "link", "heading"])
}
type AXTree struct {
Nodes []AXNode `json:"nodes"`
}
type AXNode struct {
NodeID string `json:"nodeId"`
Role string `json:"role"`
Name string `json:"name"`
Description string `json:"description"`
Value string `json:"value,omitempty"`
Properties map[string]string `json:"properties,omitempty"`
Children []string `json:"children,omitempty"` // IDs de hijos
}
```
### Comandos CDP necesarios
```go
// 1. Habilitar dominio Accessibility
{"method": "Accessibility.enable"}
// 2. Obtener árbol completo
{"method": "Accessibility.getFullAXTree", "params": {}}
// O para un nodo específico:
{"method": "Accessibility.getPartialAXTree", "params": {"nodeId": ...}}
```
## Casos de uso
- LLMs pueden entender mejor la estructura de la página
- Identificar elementos interactuables automáticamente
- Testing de accesibilidad
- Generar selectores semánticos
- Scraping inteligente basado en roles ARIA
## Ventajas sobre DOM normal
- Información semántica rica
- Roles ARIA explícitos
- Nombres accesibles computados
- Estructura más simple que DOM HTML
- Ideal para navegación por agentes autónomos
## Referencias
- CDP Accessibility Domain: https://chromedevtools.github.io/devtools-protocol/tot/Accessibility/
- WAI-ARIA Roles: https://www.w3.org/TR/wai-aria-1.2/#role_definitions
- Chrome AX Tree Inspector: chrome://accessibility
@@ -0,0 +1,191 @@
# Issue #003: Administración avanzada de cookies del perfil
**Tipo**: Enhancement
**Prioridad**: Media
**Estado**: Pendiente
## Descripción
Mejorar las capacidades de gestión de cookies persistentes en perfiles de navegador, permitiendo importar/exportar y gestionar cookies antes y después del lanzamiento del navegador.
## Funcionalidad deseada
### Gestión de cookies en runtime (ya implementado parcialmente)
-`GetCookies()` - Obtener cookies de URLs específicas
-`SetCookie()` - Establecer cookies individuales
-`ClearCookies()` - Limpiar todas las cookies
### Nuevas funcionalidades necesarias
#### Importar/Exportar
- Exportar todas las cookies del perfil a archivo JSON
- Importar cookies desde archivo JSON
- Formato compatible con extensiones de Chrome (EditThisCookie, etc.)
- Soportar formato Netscape (cookies.txt)
#### Gestión offline de perfiles
- Leer cookies del perfil sin lanzar navegador
- Modificar cookies del perfil en disco
- Copiar cookies entre perfiles
- Backup/restore de cookies
#### Filtrado y búsqueda
- Listar todas las cookies del perfil actual
- Filtrar cookies por dominio
- Filtrar cookies por nombre
- Buscar cookies por patrón
#### Configuración previa al lanzamiento
- Establecer cookies iniciales antes de lanzar navegador
- Cargar cookies desde archivo al inicio
- Configurar cookies de sesión específicas
## Implementación técnica
### Archivos sugeridos
- `pkg/browser/profile_cookies.go` - Gestión avanzada
- `pkg/browser/cookie_import_export.go` - I/O de archivos
### API propuesta
```go
// === Gestión en runtime ===
// GetAllCookies obtiene todas las cookies del navegador actual
func (b *Browser) GetAllCookies(ctx context.Context) ([]*Cookie, error)
// FilterCookies obtiene cookies que coinciden con filtros
func (b *Browser) FilterCookies(ctx context.Context, filter CookieFilter) ([]*Cookie, error)
type CookieFilter struct {
Domain string // Filtrar por dominio (ej: ".example.com")
Name string // Filtrar por nombre exacto
Pattern string // Regex para nombre o valor
}
// === Import/Export ===
// ExportCookies exporta cookies a archivo JSON
func (b *Browser) ExportCookies(ctx context.Context, filepath string, format CookieFormat) error
// ImportCookies importa cookies desde archivo
func (b *Browser) ImportCookies(ctx context.Context, filepath string, format CookieFormat) error
type CookieFormat string
const (
CookieFormatJSON CookieFormat = "json" // JSON estándar
CookieFormatNetscape CookieFormat = "netscape" // cookies.txt
CookieFormatChrome CookieFormat = "chrome" // Formato EditThisCookie
)
// === Gestión offline de perfiles ===
// Profile representa un perfil de navegador
type Profile struct {
Name string
Path string
}
// ListProfiles lista todos los perfiles disponibles
func ListProfiles() ([]Profile, error)
// GetProfileCookies lee cookies de un perfil sin lanzar navegador
func GetProfileCookies(profilePath string) ([]*Cookie, error)
// SetProfileCookies escribe cookies en un perfil sin lanzar navegador
func SetProfileCookies(profilePath string, cookies []*Cookie) error
// CopyProfileCookies copia cookies entre perfiles
func CopyProfileCookies(srcProfile, dstProfile string) error
// === Configuración inicial ===
// LaunchWithCookies lanza navegador con cookies precargadas
func LaunchWithCookies(ctx context.Context, config *Config, cookiesFile string) (*Browser, error)
// Config.InitialCookies - campo para establecer cookies al inicio
type Config struct {
// ... campos existentes ...
InitialCookies []*Cookie // Cookies a establecer al lanzar
CookiesFile string // Archivo de cookies a cargar
}
```
### Formato JSON de cookies
```json
[
{
"name": "session_id",
"value": "abc123",
"domain": ".example.com",
"path": "/",
"expires": 1735689600,
"httpOnly": true,
"secure": true,
"sameSite": "Lax"
}
]
```
### Ubicación de cookies en perfil Chrome
```
~/.navegator/profiles/<nombre>/
├── Cookies # Base de datos SQLite con cookies
├── Cookies-journal # Journal de transacciones
└── ...
```
## Casos de uso
### Caso 1: Migrar sesión entre perfiles
```go
// Exportar cookies del perfil A
browserA.ExportCookies(ctx, "session.json", CookieFormatJSON)
// Importar en perfil B
browserB.ImportCookies(ctx, "session.json", CookieFormatJSON)
```
### Caso 2: Backup de sesión autenticada
```go
// Guardar estado de sesión actual
b.ExportCookies(ctx, "backup_session.json", CookieFormatJSON)
// Restaurar más tarde
b2.ImportCookies(ctx, "backup_session.json", CookieFormatJSON)
```
### Caso 3: Lanzar con sesión precargada
```go
config := browser.DefaultConfig()
config.CookiesFile = "authenticated_session.json"
b, _ := browser.Launch(ctx, config)
// Ya está autenticado al iniciar
```
### Caso 4: Sincronizar cookies entre máquinas
```go
// Máquina A - exportar
GetProfileCookies("~/.navegator/profiles/main").Export("cookies.json")
// Máquina B - importar
SetProfileCookies("~/.navegator/profiles/main", LoadCookies("cookies.json"))
```
## Consideraciones de seguridad
⚠️ **Importante**: Las cookies pueden contener tokens de sesión y datos sensibles
- Advertir al usuario sobre seguridad de archivos exportados
- Opción para encriptar archivos de cookies
- No guardar cookies de sesión por defecto
- Limpiar cookies sensibles en exports
## Referencias
- CDP Network.getCookies: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-getCookies
- CDP Storage.getCookies: https://chromedevtools.github.io/devtools-protocol/tot/Storage/#method-getCookies
- Chrome Cookies DB: SQLite format
- Netscape cookies.txt: http://fileformats.archiveteam.org/wiki/Netscape_cookies.txt
@@ -0,0 +1,288 @@
# Issue #004: Administración de extensiones de Chrome
**Tipo**: Enhancement
**Prioridad**: Media
**Estado**: Pendiente
## Descripción
Implementar sistema completo para cargar, gestionar y configurar extensiones de Chrome en perfiles de navegador.
## Funcionalidad deseada
### Carga de extensiones
- Cargar extensiones desde archivos `.crx` (empaquetadas)
- Cargar extensiones desempaquetadas (carpetas)
- Cargar múltiples extensiones simultáneamente
- Especificar extensiones en configuración de perfil
### Gestión de extensiones
- Listar extensiones instaladas en perfil
- Habilitar/deshabilitar extensiones
- Desinstalar extensiones
- Actualizar extensiones
- Obtener información de extensión (nombre, versión, ID)
### Extensiones predefinidas
- Configuraciones para extensiones populares
- uBlock Origin - bloqueador de ads
- Tampermonkey - userscripts
- Cookie editors
- Proxy switchers
- User-agent switchers
### Configuración de extensiones
- Establecer configuración de extensión desde código
- Importar/exportar configuraciones
- Templates de configuración para casos comunes
## Implementación técnica
### Archivo sugerido
`pkg/browser/extensions.go`
### Flags de Chrome necesarias
```go
// Cargar extensión específica
"--load-extension=/path/to/extension"
// Cargar múltiples extensiones
"--load-extension=/path/ext1,/path/ext2,/path/ext3"
// Deshabilitar todas excepto las especificadas
"--disable-extensions-except=/path/ext1,/path/ext2"
// Desempaquetar y cargar .crx
"--load-extension=/path/to/extension.crx"
```
### API propuesta
```go
// === Configuración de extensiones ===
type ExtensionConfig struct {
Path string // Ruta a extensión (carpeta o .crx)
ID string // ID de extensión (opcional)
Enabled bool // Habilitada por defecto
Settings map[string]string // Configuración específica
}
// Config.Extensions - campo para extensiones
type Config struct {
// ... campos existentes ...
Extensions []*ExtensionConfig // Extensiones a cargar
DisableOtherExts bool // Deshabilitar extensiones no especificadas
}
// === Gestión en runtime ===
// Extension representa una extensión instalada
type Extension struct {
ID string
Name string
Version string
Path string
Enabled bool
Description string
}
// ListExtensions lista extensiones instaladas en el navegador actual
func (b *Browser) ListExtensions(ctx context.Context) ([]*Extension, error)
// LoadExtension carga una extensión en runtime
func (b *Browser) LoadExtension(ctx context.Context, path string) (*Extension, error)
// EnableExtension habilita una extensión
func (b *Browser) EnableExtension(ctx context.Context, extensionID string) error
// DisableExtension deshabilita una extensión
func (b *Browser) DisableExtension(ctx context.Context, extensionID string) error
// RemoveExtension desinstala una extensión
func (b *Browser) RemoveExtension(ctx context.Context, extensionID string) error
// GetExtensionSettings obtiene configuración de una extensión
func (b *Browser) GetExtensionSettings(ctx context.Context, extensionID string) (map[string]interface{}, error)
// SetExtensionSettings establece configuración de extensión
func (b *Browser) SetExtensionSettings(ctx context.Context, extensionID string, settings map[string]interface{}) error
// === Extensiones predefinidas ===
// PresetExtensions contiene configuraciones de extensiones populares
var PresetExtensions = map[string]*ExtensionConfig{
"ublock-origin": {
Path: "~/.navegator/extensions/ublock-origin",
ID: "cjpalhdlnbpafiamejdnhcphjbkeiagm",
},
"tampermonkey": {
Path: "~/.navegator/extensions/tampermonkey",
ID: "dhdgffkkebhmkfjojejmpbldmpobfkfo",
},
}
// LoadPresetExtension carga una extensión predefinida
func LoadPresetExtension(name string) (*ExtensionConfig, error)
```
### Estructura de directorio de extensiones
```
~/.navegator/
├── profiles/
│ └── <nombre>/
│ └── Extensions/ # Extensiones instaladas del perfil
│ └── <extension-id>/
│ └── <version>/
└── extensions/ # Extensiones compartidas
├── ublock-origin/
│ ├── manifest.json
│ └── ...
└── tampermonkey/
├── manifest.json
└── ...
```
## Casos de uso
### Caso 1: Lanzar con uBlock Origin
```go
config := browser.DefaultConfig()
config.Extensions = []*browser.ExtensionConfig{
{Path: "/path/to/ublock-origin"},
}
b, _ := browser.Launch(ctx, config)
```
### Caso 2: Cargar extensión en runtime
```go
ext, _ := b.LoadExtension(ctx, "/path/to/extension")
log.Printf("Cargada: %s v%s\n", ext.Name, ext.Version)
```
### Caso 3: Usar extensión predefinida
```go
config := browser.DefaultConfig()
ublock, _ := browser.LoadPresetExtension("ublock-origin")
config.Extensions = []*browser.ExtensionConfig{ublock}
b, _ := browser.Launch(ctx, config)
```
### Caso 4: Gestionar extensiones existentes
```go
// Listar todas
exts, _ := b.ListExtensions(ctx)
for _, ext := range exts {
log.Printf("%s: %s\n", ext.Name, ext.Enabled)
}
// Deshabilitar extensión específica
b.DisableExtension(ctx, "extension-id-here")
```
### Caso 5: Configurar extensión
```go
// Configurar uBlock Origin con listas personalizadas
b.SetExtensionSettings(ctx, "cjpalhdlnbpafiamejdnhcphjbkeiagm", map[string]interface{}{
"customFilterLists": []string{
"https://example.com/my-filters.txt",
},
})
```
## Extensiones útiles para automatización
### Stealth y anti-detección
- **Buster**: Solver de CAPTCHAs
- **User-Agent Switcher**: Cambiar user agent
- **Canvas Fingerprint Defender**: Anti-fingerprinting
### Scraping
- **uBlock Origin**: Bloquear ads y trackers
- **Cookie Editor**: Gestión avanzada de cookies
- **Header Editor**: Modificar headers HTTP
### Automatización
- **Tampermonkey**: Ejecutar userscripts personalizados
- **Violentmonkey**: Alternativa a Tampermonkey
### Desarrollo
- **React DevTools**: Inspeccionar componentes React
- **Vue.js DevTools**: Inspeccionar aplicaciones Vue
- **Redux DevTools**: Debugging de estado Redux
## Obtener extensiones
### Chrome Web Store
```bash
# URL de extensión en Chrome Web Store
https://chrome.google.com/webstore/detail/<extension-id>
# Descargar .crx con herramientas
# https://github.com/Rob--W/crxviewer
```
### Desarrollo local
```bash
# Crear extensión simple
mkdir my-extension
cd my-extension
cat > manifest.json <<EOF
{
"manifest_version": 3,
"name": "My Extension",
"version": "1.0",
"description": "Custom extension"
}
EOF
```
## CDP para gestión de extensiones
CDP no tiene soporte directo robusto para extensiones, pero podemos:
1. **Launch flags**: Usar `--load-extension` al inicio
2. **Service workers**: Comunicarse con background scripts de extensión vía `chrome.runtime`
3. **Extension pages**: Navegar a `chrome-extension://<id>/page.html`
4. **Local storage**: Acceder a storage de extensión si es accesible
```go
// Ejecutar código en contexto de extensión
script := fmt.Sprintf(`
chrome.runtime.sendMessage('%s', {action: 'configure'}, response => {
return response;
});
`, extensionID)
```
## Consideraciones especiales
### Manifest V3 vs V2
- Chrome está migrando a Manifest V3
- Algunas extensiones V2 dejarán de funcionar
- Verificar compatibilidad al cargar extensiones
### Permisos
- Extensiones pueden requerir permisos específicos
- Algunas operaciones requieren interacción manual la primera vez
- Considerar pre-configurar permisos en perfil
### Actualizaciones
- Extensiones de Chrome Web Store se actualizan automáticamente
- Extensiones locales no se actualizan
- Implementar sistema de actualización manual si es necesario
### Headless mode
- Algunas extensiones no funcionan en modo headless
- Extensiones con UI pueden requerir modo visible
- Probar compatibilidad con `--headless=new`
## Referencias
- Chrome Extensions: https://developer.chrome.com/docs/extensions/
- Load unpacked extensions: https://developer.chrome.com/docs/extensions/mv3/getstarted/
- Chrome Extension IDs: https://robwu.nl/crxviewer/
- Manifest V3: https://developer.chrome.com/docs/extensions/mv3/intro/
- Extension CLI flags: https://peter.sh/experiments/chromium-command-line-switches/
@@ -0,0 +1,167 @@
# Issue #005: Eliminar timeouts innecesarios del código
**Tipo**: Improvement
**Prioridad**: Alta
**Estado**: Pendiente
## Descripción
Eliminar todos los `time.Sleep()` y timeouts hardcodeados innecesarios del código, reemplazándolos con esperas basadas en eventos CDP cuando sea posible.
## Problema actual
El código tiene múltiples `time.Sleep()` arbitrarios:
- `time.Sleep(2 * time.Second)` en examples/basic.go
- `time.Sleep(3 * time.Second)` en cmd/list_blog.go
- Timeouts hardcodeados en navegación
Estos timeouts son problemáticos porque:
- No se adaptan a velocidad real de carga
- Desperdicían tiempo en conexiones rápidas
- Fallan en conexiones lentas
- Hacen el código menos robusto
## Estrategia de reemplazo
### 1. Eventos CDP de carga de página
En lugar de:
```go
b.Navigate(ctx, url, nil)
time.Sleep(3 * time.Second)
```
Usar eventos CDP:
```go
opts := browser.DefaultNavigateOptions()
opts.WaitUntil = "networkidle" // o "load" o "domcontentloaded"
b.Navigate(ctx, url, opts)
// No sleep necesario, Navigate espera el evento
```
### 2. Esperar por selectores
En lugar de:
```go
time.Sleep(2 * time.Second)
html, _ := b.GetHTML(ctx, ".content")
```
Usar:
```go
b.WaitForSelector(ctx, ".content", 30*time.Second)
html, _ := b.GetHTML(ctx, ".content")
```
### 3. Esperar por condiciones JavaScript
En lugar de:
```go
time.Sleep(1 * time.Second)
result, _ := b.Evaluate(ctx, "window.dataReady")
```
Usar:
```go
b.WaitForFunction(ctx, "window.dataReady === true", 100*time.Millisecond)
result, _ := b.Evaluate(ctx, "window.data")
```
### 4. Eventos de red
Esperar que network esté idle:
```go
// Implementar WaitForNetworkIdle()
b.WaitForNetworkIdle(ctx, 500*time.Millisecond, 30*time.Second)
```
## Eventos CDP útiles
### Page domain
- `Page.loadEventFired` - Página cargada completamente
- `Page.domContentEventFired` - DOM listo
- `Page.frameStoppedLoading` - Frame dejó de cargar
### Network domain
- `Network.requestWillBeSent` - Request iniciado
- `Network.responseReceived` - Response recibida
- `Network.loadingFinished` - Recurso terminó de cargar
- `Network.loadingFailed` - Recurso falló
## Métodos a implementar
```go
// WaitForEvent espera un evento CDP específico
func (b *Browser) WaitForEvent(ctx context.Context, eventName string, timeout time.Duration) error
// WaitForNetworkIdle espera que no haya requests de red por X tiempo
func (b *Browser) WaitForNetworkIdle(ctx context.Context, idleTime, timeout time.Duration) error
// WaitForFunction espera que una función JS retorne true
func (b *Browser) WaitForFunction(ctx context.Context, fn string, checkInterval time.Duration) error
// WaitForNavigation espera que navegación complete
func (b *Browser) WaitForNavigation(ctx context.Context, timeout time.Duration) error
```
## Archivos a revisar y actualizar
- [x] `examples/basic.go` - Eliminar time.Sleep
- [x] `examples/advanced.go` - Reemplazar con esperas basadas en eventos
- [x] `cmd/list_blog.go` - Usar WaitForSelector
- [ ] `pkg/browser/navigation.go` - Mejorar Navigate() para esperar eventos
- [ ] `pkg/browser/browser.go` - Agregar métodos de espera
## Implementación en Navigate()
```go
func (b *Browser) Navigate(ctx context.Context, url string, opts *NavigateOptions) error {
if opts == nil {
opts = DefaultNavigateOptions()
}
// Registrar listener ANTES de navegar
loadedChan := make(chan struct{})
b.client.On("Page.loadEventFired", func() {
close(loadedChan)
})
// Enviar comando de navegación
_, err := b.client.SendCommand(ctx, "Page.navigate", map[string]interface{}{
"url": url,
})
if err != nil {
return err
}
// Esperar evento según opts.WaitUntil
select {
case <-loadedChan:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
```
## Beneficios
**Más rápido**: No espera más de lo necesario
**Más robusto**: Falla con timeout claro, no con misterioso "elemento no encontrado"
**Más confiable**: Se adapta a velocidad real de página
**Mejor UX**: Feedback claro de qué se está esperando
## Testing
Probar con:
- Conexiones rápidas (localhost)
- Conexiones lentas (throttling)
- Páginas con mucho JavaScript
- Páginas con assets pesados
- SPAs (React, Vue) que cargan async
## Referencias
- CDP Page events: https://chromedevtools.github.io/devtools-protocol/tot/Page/#event-loadEventFired
- CDP Network events: https://chromedevtools.github.io/devtools-protocol/tot/Network/
- Puppeteer waitFor: https://pptr.dev/guides/page-interactions#waiting