---
name: browser-interaction-patterns
description: >
  Patrones canónicos para automatización de browser via CDP (Chrome DevTools Protocol):
  dialogs nativos, iframes y shadow DOM, dropdowns, downloads y uploads, screenshots,
  scrolling, cookies, network requests, tabs y viewport. Cargar cuando una sesión
  de browser automation tropieza con mecánica específica de UI (frame stuck, dropdown
  invisible, dialog colgando JS, upload sin progresar) y agent-browser no resuelve
  por defecto. Complementa a agent-browser con patrones de bajo nivel CDP.
version: "1.0.0"
herramientasPermitidas: [Read, Bash, WebFetch]
evolved: false
fuente: "browser-use/browser-harness — interaction-skills (MIT License, 2026)"
evolvable: true
exclusiones:
  - "No cargar para scraping de páginas estáticas sin interacción — usar WebFetch o web-fetcher-routing."
  - "No cargar para research técnico de research-domains (github, arxiv, etc.) — esos viven en browser-research-domains."
  - "No cargar para automatización Playwright/Selenium tradicional — este skill asume CDP directo o agent-browser CLI."
  - "No cargar como reemplazo de agent-browser — es complemento; siempre cargar primero agent-browser y solo escalar aquí ante tropiezos específicos."
---

# Skill: browser-interaction-patterns

Patrones de browser automation a nivel CDP para resolver tropiezos comunes que
`agent-browser` por sí solo no documenta. Cada sección entrega: anti-patrón
observable, regla canónica, ejemplo concreto y gotcha clave.

Adaptado de `browser-use/browser-harness` (MIT License) — los patrones son
agnósticos al runtime CLI específico (`browser-harness` o `agent-browser`).

## Cuándo cargar

- El agente tropieza con un dialog `alert/confirm/prompt` que congela la página.
- Una página tiene iframe cross-origin o shadow DOM y el selector falla.
- Un dropdown nativo `<select>` no responde a click sintético.
- Una descarga arranca pero no hay forma de verificar que terminó.
- Un upload con `<input type="file">` no acepta el archivo.
- El agente abre una pestaña y todas las acciones siguientes ocurren "invisibles" al usuario.
- Una SPA cambia de ruta y el agente actúa antes de que termine la hidratación.

## Cuándo NO cargar

- Páginas estáticas HTML sin interacción → `WebFetch` o `web-fetcher-routing`.
- Tareas de research técnico contra APIs documentadas → `browser-research-domains`.
- Tests E2E con framework dedicado (Playwright, Cypress, Detox) → skill correspondiente.

## Regla universal — verificar con screenshot

Tras cualquier acción que modifique estado (click, navigation, form submit,
upload, dialog dismiss), **re-capturar screenshot antes de asumir éxito**.
La verificación visual es la única señal confiable de que el compositor de Chrome
aplicó el cambio. `page_info()` o assertions del DOM pueden mentir si la página
está en transición.

---

## 1. Dialogs nativos (alert / confirm / prompt / beforeunload)

Los dialogs nativos congelan el thread de JS hasta resolverse. Si el agente
intenta cualquier acción mientras hay dialog abierto, todo cuelga.

### Detección — `page_info()` auto-surface

Cuando hay dialog pendiente, `page_info()` retorna `{"dialog": {type, message, ...}}`
en lugar del viewport dict habitual. Verificar antes de actuar.

### Dismiss reactivo via CDP (preferido)

Funciona aún con JS frozen. Maneja todos los tipos incluyendo `beforeunload`.
NO detectable por antibot — no inyecta JS al page.

```python
cdp("Page.handleJavaScriptDialog", accept=True)   # OK
cdp("Page.handleJavaScriptDialog", accept=False)  # Cancel

events = drain_events()
for e in events:
    if e["method"] == "Page.javascriptDialogOpening":
        print(e["params"]["type"])     # alert/confirm/prompt/beforeunload
        print(e["params"]["message"])
```

### Stub proactivo via JS (cuando esperas múltiples alerts)

Tradeoffs: detectable por antibot, NO maneja `beforeunload`, se pierde al navegar.

```python
js("""
window.__dialogs__=[];
window.alert=m=>window.__dialogs__.push(String(m));
window.confirm=m=>{window.__dialogs__.push(String(m));return true;};
window.prompt=(m,d)=>{window.__dialogs__.push(String(m));return d||'';};
""")
```

**Gotcha**: `beforeunload` aparece al navegar con cambios sin guardar. Manejar
SIEMPRE con CDP, nunca con stub JS.

---

## 2. Iframes y cross-origin

### Same-origin

Acceder via `contentDocument` / `contentWindow` desde el contexto padre.
Las coordenadas de click son **frame-local**, no de la página completa.

### Cross-origin — usar `iframe_target`

El iframe tiene su propio target CDP. Attach con `iframe_target(url_substr)` y
pasar el `target_id` a `js(..., target_id=tid)`.

```python
tid = iframe_target("stripe.com/checkout")
saldo = js("document.querySelector('.balance').textContent", target_id=tid)
```

### Mejor opción para clicks en iframe — coordinate clicks

`Input.dispatchMouseEvent` opera a nivel **compositor** de Chrome y pasa por
iframes cross-origin sin trabajo extra. Es preferible a navegar el árbol de
targets cuando el objetivo es visible.

```python
# Click en botón visible dentro de iframe cross-origin
click_at_xy(x, y)  # las coordenadas del screenshot funcionan tal cual
capture_screenshot()  # verificar
```

**Gotcha**: solo bajar a DOM iframe cuando el target NO tiene geometría visible
(hidden input, 0×0 node).

---

## 3. Shadow DOM

DOM oculto en componentes Web. `querySelector` desde el documento root NO
penetra `shadowRoot`.

### Traversal recursivo cuando hay que penetrar

```javascript
function deepQuerySelector(root, selector) {
  const found = root.querySelector(selector);
  if (found) return found;
  for (const el of root.querySelectorAll('*')) {
    if (el.shadowRoot) {
      const inShadow = deepQuerySelector(el.shadowRoot, selector);
      if (inShadow) return inShadow;
    }
  }
  return null;
}
```

### Mejor opción — coordinate clicks

Si el elemento es visible, `click_at_xy(x, y)` cruza shadow DOM sin necesidad
de penetrar. Mismo principio que iframes cross-origin: operar a nivel
compositor evita la complejidad de los árboles anidados.

**Gotcha**: componentes Polymer/Lit/Stencil que componen 5+ shadow roots
anidados son frágiles para selectors. Default a screenshots + coordinate clicks.

---

## 4. Dropdowns

Cuatro variantes con tratamiento distinto:

### Native `<select>`

NO responde a `click` sintético. Usar:

```python
js("""
const sel = document.querySelector('select#country');
sel.value = 'MX';
sel.dispatchEvent(new Event('change', {bubbles:true}));
""")
```

### Custom overlay (Material, Ant Design, Bootstrap)

Render con `position: fixed` o `position: absolute`, geometría aparece **después**
de click en el trigger. Patrón obligatorio:

```python
click_at_xy(trigger_x, trigger_y)
wait(0.3)  # esperar render del overlay
capture_screenshot()  # re-medir geometría
# leer la posición de la opción del screenshot nuevo
click_at_xy(option_x, option_y)
```

### Searchable combobox (React Select, Algolia)

Escribir texto en el input filtra opciones. `type_text` puede no disparar el
filtro si el componente espera eventos `input`/`change` reales. Usar
`fill_input` que dispara los eventos sintéticos.

### Virtualized menu (react-window, AG-Grid)

La opción que buscas puede no estar en el DOM hasta hacer scroll dentro del
overlay. Hacer `scroll(x, y, dy=N)` sobre las coordenadas del overlay.

---

## 5. Downloads

Dos modos: browser-triggered (con CDP) vs HTTP directo.

### HTTP directo cuando se conoce la URL del archivo

```python
# Para PDFs, CSVs, ZIP con URL conocida — más rápido y observable
import urllib.request
urllib.request.urlretrieve(url, "/tmp/file.pdf")
```

### Browser-triggered (click en "Download")

```python
cdp("Page.setDownloadBehavior", behavior="allow", downloadPath="/tmp/dl")
click_at_xy(download_btn_x, download_btn_y)
# Verificar archivo aparece en /tmp/dl/ con tamaño > 0
```

**Gotcha**: muchos sites generan el archivo on-the-fly (POST + redirect a S3
con presigned URL). El `setDownloadBehavior` captura, pero la verificación es
por filesystem polling, no por DOM signal.

---

## 6. Uploads

`<input type="file">` rechaza interacción sintética por sandbox del browser.
Usar CDP `DOM.setFileInputFiles`:

```python
upload_file("input[type=file]", "/abs/path/file.pdf")
```

### Drag-and-drop como alternativa

Si el site solo acepta drop (no input file visible), construir eventos
`DragEvent` con `DataTransfer` poblada. Si el site usa un componente complejo
(Filepond, Dropzone), preferir descubrir el `<input>` oculto (siempre existe
para fallback A11y) y usar `setFileInputFiles` sobre ese.

**Gotcha**: path DEBE ser absoluto. Path relativo silently falla sin error
visible.

---

## 7. Screenshots

`capture_screenshot()` escribe PNG del viewport actual. El archivo está en
**device pixels**: en una pantalla 2× un viewport 2296×1143 CSS produce PNG
4592×2286.

### Coordenadas

Click coordinates son **CSS pixels**. NO leas posición del PNG y pases directo
a `click_at_xy()` sin dividir por `devicePixelRatio`:

```python
dpr = js("window.devicePixelRatio") or 1
# Si lees (px, py) del PNG, convertir:
click_at_xy(px / dpr, py / dpr)
```

### Límite 2000 px para LLMs vision

Algunos modelos rechazan imágenes > 2000 px/lado. Pasar `max_dim=1800` para
downscale automático:

```python
capture_screenshot("/tmp/shot.png", max_dim=1800)
```

Solo downscalea si excede; seguro dejar siempre activo.

### Full-page vs viewport

`full=True` captura todo el documento (puede ser MB). Usar solo cuando
necesites contenido bajo el fold; viewport es mucho más rápido.

---

## 8. Scrolling

Identificar qué elemento consume wheel events ANTES de hacer scroll.

| Caso | Comando |
|------|---------|
| Scroll de page | `scroll(x, y, dy=-300)` con coordenadas dentro del viewport principal |
| Scroll de contenedor anidado (div con overflow) | `scroll(x, y, ...)` con coordenadas dentro del contenedor |
| Scroll de virtualized list (react-window) | Igual que contenedor; pero verificar con screenshot que la lista realmente avanzó (puede tener su propio scroll handler) |
| Scroll de dropdown menu | Coordenadas dentro del overlay tras abrirlo |

**Gotcha**: `js("window.scrollBy(0, 300)")` funciona en page pero NO en
contenedores anidados. Para contenedores anidados, usar `Input.dispatchMouseEvent`
con `type=mouseWheel` (que es lo que hace `scroll(...)`).

---

## 9. Cookies

Cookies son **browser state**, no page state. Acceder via CDP, no via
`document.cookie` que solo ve cookies del path actual sin `HttpOnly`.

```python
cookies = cdp("Network.getCookies")["cookies"]
cdp("Network.setCookie", name="session", value="abc", domain=".example.com", path="/")
cdp("Network.clearBrowserCookies")
```

**Gotcha**: cookies `HttpOnly` y `Secure` NO aparecen en `document.cookie`.
Para auth tokens, SIEMPRE usar `Network.getCookies` CDP.

---

## 10. Network requests

Cuando una acción no produce cambio DOM visible (form submit, SPA route change,
SSE keepalive), inferir desde tráfico de red.

### Patrón — esperar idle de red post-acción

```python
# El helper canónico — espera N ms sin requests in-flight
wait_for_network_idle(timeout=10.0, idle_ms=500)
```

Equivalente conceptual usando `drain_events`:

```python
deadline = time.time() + 10
in_flight = set()
last_activity = time.time()
while time.time() < deadline:
    for e in drain_events():
        m = e.get("method", "")
        if m == "Network.requestWillBeSent":
            in_flight.add(e["params"]["requestId"])
            last_activity = time.time()
        elif m in ("Network.loadingFinished", "Network.loadingFailed"):
            in_flight.discard(e["params"]["requestId"])
            last_activity = time.time()
    if not in_flight and (time.time() - last_activity) * 1000 >= 500:
        break
    time.sleep(0.1)
```

**Gotcha**: filtrar eventos por `session_id` activo. Tabs en background
(polling/SSE) inyectan eventos al buffer global y envenenan el idle check.

---

## 11. Tabs

**Regla**: CDP para control, UI automation para orden visible.

### CDP — qué hace bien

- Crear tab (`new_tab(url)`)
- Attach a target conocido
- Activar tab conocida (`Target.activateTarget`)
- Capturar screenshot del tab attached incluso si otro está al frente visible
- Inspeccionar URL/title/viewport

### CDP — qué hace MAL

- Matchear el orden visible left-to-right del tab strip del usuario
- Distinguir target real vs omnibox popup sin filtrar URLs

### Orden visible

- **macOS**: AppleScript (`tell application "Google Chrome" / every tab of front window`)
- **Linux**: `xdotool`, `wmctrl`, scripting del WM
- **Windows**: UI Automation framework

### Reglas confirmadas

- `switch_tab()` NO es suficiente si el usuario espera ver el cambio en Chrome → llamar también `Target.activateTarget`.
- `list_tabs()` incluye `chrome://newtab/` por default → `include_chrome=False` para solo páginas reales.
- `chrome://omnibox-popup.top-chrome/` aparece como page target falso → ignorar.
- Si una page reporta `w=0 h=0`, probablemente attached al target equivocado.

---

## 12. Viewport

Cambios de viewport afectan layout, coordenadas y workflows que dependen de
geometría estable.

```python
cdp("Emulation.setDeviceMetricsOverride",
    width=1280, height=800, deviceScaleFactor=1, mobile=False)
```

**Gotcha**: tras cambiar viewport, **re-capturar screenshot y re-medir todas
las coordenadas**. Posiciones de elementos cambian incluso con cambios mínimos
de ancho. Para tests responsive, capturar 320/375/768/1024/1440 y verificar
cada uno.

---

## 13. Print as PDF

CDP genera PDF sin necesidad de click en botón de impresión:

```python
result = cdp("Page.printToPDF", printBackground=True, preferCSSPageSize=True)
with open("/tmp/page.pdf", "wb") as f:
    f.write(base64.b64decode(result["data"]))
```

**Gotcha**: si el site fuerza un botón "Print" custom que abre dialog del
sistema, el botón debe clickearse PRIMERO y luego CDP captura la página
renderizada. La regla de oro: `Page.printToPDF` siempre funciona en la página
visible; el botón solo lo necesitas cuando el site ajusta layout para print
(`@media print`).

---

## 14. Connection y tab visibility

Al arrancar Chrome fresh, los únicos targets `type: "page"` pueden ser
`chrome://inspect` y `chrome://omnibox-popup.top-chrome/` (1px invisible). Si
el daemon attached al omnibox popup, **TODAS** las acciones siguientes —
incluyendo `new_tab()` y `goto_url()` — ocurren en tabs CDP pero NO visibles
al usuario.

### Sequence de bootstrap

```python
if not daemon_alive():
    cleanup_sockets()  # limpiar PIDs/sockets stale
    ensure_daemon()

tabs = list_tabs()
for t in tabs:
    print(t["url"][:60])

tab = ensure_real_tab()  # attach a tab real, no chrome://
```

### Llevar Chrome al frente

```python
# macOS
subprocess.run(["osascript", "-e",
                'tell application "Google Chrome" to activate'])
# Linux con wmctrl
subprocess.run(["wmctrl", "-a", "Google Chrome"])
```

---

## Patrones transversales

### Coordinate clicks > selector clicks

`Input.dispatchMouseEvent` opera a nivel compositor de Chrome y pasa por
iframes, shadow DOM y cross-origin sin trabajo extra. Default a coordenadas
si el elemento es visible; bajar a DOM solo cuando NO hay geometría.

### Screenshots primero para verificación

Tras cualquier acción que modifica estado, capturar screenshot ANTES de
asumir éxito. Es la única señal confiable de que el compositor aplicó el
cambio.

### `wait_for_network_idle` > `wait(N)` fijo

Esperar event-driven (CDP Network events) en lugar de timeout ciego. Más
rápido en páginas rápidas, más confiable en lentas.

### Re-medir geometría post-acción

Tras abrir dropdown / modal / overlay, screenshot + leer coordenadas nuevas.
NO reutilizar posiciones medidas antes de la acción — la geometría cambió.

---

## Gotchas que cuestan horas

- **`click_at_xy(x, y)` falla sin error** cuando coordenadas caen en zona
  cubierta por dialog/modal/overlay invisible. Verificar con screenshot ANTES.
- **`document.cookie` no ve `HttpOnly` cookies**. Auth tokens viven ahí —
  usar `Network.getCookies` CDP.
- **`wait_for_load()` retorna True antes de hidratación React/Vue**. Para SPAs,
  añadir `wait_for_element(selector_clave)` después.
- **`new_tab()` puede abrir tab que no es la activa**. Llamar
  `Target.activateTarget` para que sea visible al usuario.
- **Path absoluto OBLIGATORIO** en `setFileInputFiles`. Path relativo falla
  silencioso.
- **Tabs en background siguen emitiendo Network events** al buffer global.
  Filtrar por `session_id` activo en `wait_for_network_idle`.

---

## Relación con otras skills

- **`agent-browser`**: skill base. Cargar primero. Solo escalar aquí ante
  tropiezos específicos.
- **`web-fetcher-routing`**: orquesta WebFetch vs agent-browser vs markitdown.
  Si la página es estática, no necesitas este skill.
- **`browser-research-domains`**: patrones específicos por dominio (github,
  arxiv, etc.) que evitan browser cuando hay API.

<!-- Adaptado de browser-use/browser-harness bajo MIT License (browser-use, 2026). -->
