# Patrones probados de Playwright contra Bucéfalo / GHL

Este documento es la post-mortem del trabajo de hacer que el auto-login con 2FA por correo funcione end-to-end. Sirve como **referencia para futuros scripts** de automatización contra la UI de Bucéfalo (o GHL en general).

Va de la mano con:
- [PLAYWRIGHT_SESSION.md](PLAYWRIGHT_SESSION.md) — cómo manejar la sesión (storage_state vs perfil persistente, auto-login con `.env`).
- `memory/ghl_ui_quirks.md` — quirks operativos de la UI de Bucéfalo.

---

## Caso de estudio: el auto-login con 2FA

### Problema original

Tras configurar credenciales en `.env`, el botón "Renovar sesión Bucéfalo" disparaba un subprocess Playwright que debía:

1. Llenar email + contraseña.
2. Click en "Iniciar sesión".
3. Seleccionar el método 2FA "Email".
4. Esperar a que llegara el correo con el OTP.
5. Pegar el código en la UI.
6. Esperar al dashboard.

El primer intento funcionó hasta el paso 2. A partir del 3 el script reportaba "Auto-login falló" — pero el usuario veía que en realidad **el login sí completaba** (la barra de Bucéfalo mostraba "0 h de antigüedad"). Tres bugs encadenados.

### Diagnóstico: cómo lo descubrimos

Sin screenshots de debug, las iteraciones iniciales eran a ciegas. La táctica que funcionó:

1. **Agregar `_save_debug_screenshot(page, label)` en cada punto crítico** del flujo:
   - `post_login` (tras enviar credenciales)
   - `no_method_selector` (si el detector de método 2FA caduca)
   - `before_otp_input` (justo antes de pedir el OTP por IMAP)
   - `otp_input_search` (tras detectar dónde tipear el código)
   - `code_typed` (con el código ya pegado)
   - `final_state` (estado final, sea éxito o fallo)
2. **Mirar las capturas en orden** para reconstruir lo que pasó.

Las capturas revelaron los 3 bugs:

| # | Bug | Síntoma en captura |
|---|---|---|
| 1 | Bucéfalo NO muestra selector de método 2FA — manda el código directo al correo | `post_login` mostró directo la pantalla "Verificar el Código" con 6 inputs vacíos |
| 2 | El `.fill(code)` en un input con `maxlength="1"` solo acepta el primer caracter | `code_typed` mostró "8" en el primer input y los siguientes 5 vacíos |
| 3 | `_wait_for_login_completion` chequeaba solo `page.url`, pero Bucéfalo es SPA y mantiene la URL en `/` aunque el contenido sea el dashboard | `final_state` mostró el dashboard completo, pero el script reportó fallo y la URL seguía siendo `/` |

### Fixes aplicados (en orden de iteración)

**Iteración 1 — Selectores amplios para el método 2FA**: agregué 16 variaciones (button, div[role=button], radio, label, tarjetas con clase `option`/`method`, data-test-id). Hago polling 15 s buscando cualquiera de ellos. Si ninguno aparece, asumo que Bucéfalo mandó el código directo y continúo. — *Bug 1 cubierto.*

**Iteración 2 — Tipeo del OTP con `keyboard.type`**: en lugar de `input.fill(code)` (que rompe con maxlength=1), detecto los 6 inputs de un dígito (`input[maxlength="1"]`, filtrados a visibles), hago click en el primero para foco, y luego `page.keyboard.type(code, delay=80)`. Eso simula tipeo humano caracter por caracter; Vue captura cada keydown y mueve el foco automáticamente al siguiente input. — *Bug 2 cubierto.*

**Iteración 3 — Detección de login completado por DOM + URL**: `_wait_for_login_completion` ahora combina dos señales:
- URL fuera de `/login` o `/auth`.
- DOM ya no muestra el form de login (ningún `input[type="password"]` visible, ni texto "Verificar el Código", ni "Sign in to your account").

Requiere 2 polls consecutivos confirmando ambas señales (~4 s mínimo) para evitar falsos positivos durante la redirección. — *Bug 3 cubierto.*

### Resultado verificado

Corrida final desde terminal:

```
[AUTO] Detectados 6 inputs de 1 dígito (input[maxlength="1"]).
[AUTO] Código tipeado vía keyboard.type (modo: digits).
[INFO] URL actual: https://crm.bucefalocrm.io/
[INFO] URL actual: https://crm.bucefalocrm.io/agency_dashboard?tab=summary
[INFO] Login completado (URL fuera del login + DOM sin form de credenciales).
[ÉXITO] Sesión guardada en: H:\...\generated\browser\session.json
```

Tiempo total: ~30-45 s desde "click en Renovar" hasta sesión guardada.

---

## Lecciones generales para scripts contra Bucéfalo / SPAs

### 1. Nunca confíes solo en la URL

Bucéfalo (y GHL en general) son SPAs Vue/React. Las transiciones internas:

- **Pueden mantener la URL** mientras cambian el contenido (route guard, replaceState, re-render del root).
- **Pueden mostrar el form de login dentro del mismo iframe** cuando una request da 401, sin redirigir a `/login`.
- **Pueden cambiar la URL pero seguir mostrando contenido viejo** brevemente durante la hidratación.

**Patrón a usar**: combinar URL con DOM. Helpers ya existentes:
- `_any_frame_at_login(page)` en [`scripts/ghl_browser_workflow_manager.py`](../scripts/ghl_browser_workflow_manager.py) — detecta login por URL + DOM (input[type=password] visible, textos del form).
- `_looks_like_login_page(page)` en [`scripts/ghl_browser_session_generator.py`](../scripts/ghl_browser_session_generator.py) — versión similar para detectar fin del login.

### 2. Para inputs con `maxlength="1"` (PIN / OTP / tarjetas), usa `keyboard.type` con delay

`locator.fill("123456")` en un input con `maxlength="1"` se trunca y rompe el flujo. La alternativa:

```python
# Detectar inputs de un dígito (más permisivo que `[autocomplete=one-time-code]`)
inputs = page.locator('input[maxlength="1"]').all()
visible = [i for i in inputs if i.is_visible(timeout=200)]

if len(visible) >= 6:
    visible[0].click()                         # foco en el primero
    time.sleep(0.3)
    page.keyboard.type(code, delay=80)          # tipeo humano, Vue auto-advance
```

El `delay=80` ms es suficiente para que Vue/React capture cada keydown.

### 3. Selectores amplios y en cascada

Los selectores de UIs comerciales cambian con cada update. Patrón defensivo:

```python
SELECTORS = [
    'button:has-text("Email")',
    'button:has-text("Correo electrónico")',
    'div[role="button"]:has-text("Email")',
    '[data-test-id*="email"]',
    'label:has-text("Email")',
    # ...
]
deadline = time.time() + 15
while time.time() < deadline:
    for sel in SELECTORS:
        try:
            loc = scope.locator(sel).first
            if loc.count() > 0 and loc.is_visible(timeout=300):
                loc.click(timeout=2000)
                clicked = True
                break
        except Exception:
            continue
    if clicked: break
    time.sleep(1)
```

Reglas:
- **Probar texto en español Y inglés** — la UI puede estar en otro idioma según preferencia del usuario.
- **No usar `wait_for_selector` rígido con un único selector** — puede caducar 20 s antes de que pruebes otra opción.
- **Hacer polling** dentro del `while time.time() < deadline`.
- **Soportar el caso de "no apareció"** como flujo válido (a veces la UI salta pasos).

### 4. Screenshots de debug en cada punto crítico

Es la herramienta # 1 para diagnosticar. Convención del proyecto:

```python
from paths import SCREENSHOTS_DIR  # generated/browser/screenshots/

def _save_debug_screenshot(page, label):
    try:
        os.makedirs(SCREENSHOTS_DIR, exist_ok=True)
        ts = time.strftime("%Y%m%d_%H%M%S")
        path = os.path.join(SCREENSHOTS_DIR, f"{label}_{ts}.png")
        page.screenshot(path=path, full_page=True)
        print(f"[DEBUG] Captura guardada: {path}")
        return path
    except Exception:
        return None
```

Naming: `{prefijo_flujo}_{paso}_{timestamp}.png`. Ej: `autologin_post_login_20260523_184611.png`.

`scripts/cleanup_storage.py` los limpia automáticamente cada 30 días (configurable).

### 5. Reusar browser entre operaciones en bulk

Abrir/cerrar Chromium cuesta 8-10 s por operación. Para bulks con N items, el patrón es:

```python
with sync_playwright() as p:
    browser, context, page = _open_browser(p)
    _INTERRUPT_STATE["browser"] = browser
    _INTERRUPT_STATE["context"] = context
    try:
        for item in items:
            try:
                _perform_action_on_page(page, item)  # navega a la URL del item
            except SessionExpiredError:
                # abortar todo el bulk; los demás items también fallarían
                break
            except Exception as e:
                # fallo aislado, continuar con el siguiente
                ...
            time.sleep(2)  # no martillar la API de GHL
    finally:
        _close_and_save(browser, context)
```

Helpers ya disponibles:
- `_open_browser(p)` y `_close_and_save(browser, context)` en `ghl_browser_workflow_manager.py` — manejan el modo (shared `storage_state` vs perfil persistente) y refrescan cookies al cerrar.
- `_INTERRUPT_STATE` global + `_install_signal_handlers()` — garantizan cleanup limpio si el server reinicia o el usuario cancela el task.

### 6. Validar contra la API de GHL, no contra la UI

La UI de Bucéfalo tiene bugs visuales (puede mostrar "Guardado" sin haber guardado, puede no refrescar tras una mutación). La API es la fuente de verdad.

```python
# Después de mutar vía DOM, esperar y reconsultar la API.
def _verify_status_via_api(location_id, workflow_id, target_active,
                           max_attempts=6, base_wait_sec=3):
    for attempt in range(1, max_attempts + 1):
        time.sleep(base_wait_sec * attempt)  # backoff lineal
        wfs = sync_engine.ghl_client.get_workflows(token, location_id)
        actual = next((w for w in wfs if w.get("id") == workflow_id), None)
        if actual and (actual["status"] in ("active","published")) == target_active:
            return True
    return False
```

GHL puede tardar hasta 20 s en propagar un cambio del builder a su API → reintentar con backoff es esencial.

### 7. Excepciones tipadas para flujos especiales

Si una condición de error invalida todo lo que sigue (sesión expirada en bulk, login redirect, etc.), levanta una excepción dedicada:

```python
class SessionExpiredError(Exception):
    """Bucéfalo redirigió al login → toda interacción posterior va a fallar."""
    pass
```

Y en el caller, captura específicamente para abortar early:

```python
for item in items:
    try:
        _perform_on_page(page, item)
    except SessionExpiredError:
        # Marcar los items restantes como skipped sin intentarlos.
        for remaining in items[idx:]:
            results.append({"status": "skipped", "reason": "sesión expirada"})
        break
    except Exception as e:
        # Error aislado, continuar.
        ...
```

Eso evita N timeouts de 30s cuando ya sabemos que todo fallará.

### 8. SSE para progreso en tiempo real

Los scripts largos (auto-login, bulks) imprimen líneas al stdout con marcadores que el frontend parsea por regex y traduce a estados humanos:

```python
# Backend (Python script):
print(f"[BULK {idx}/{total}] === '{name}' ({wf_id}) ===")
print(f"[BULK {idx}/{total}] RESULT: {status}")  # success|failed|skipped

# Frontend (JS):
const m = line.match(/\[BULK (\d+)\/(\d+)\] RESULT: (\w+)/);
if (m) { /* incrementar contador del status correspondiente */ }
```

Patrón clave: **un marcador `RESULT` único por item** (no contar líneas SKIP separadas — eso causa doble conteo). Ver `bulkParseLine` en [`static/js/app.js`](../static/js/app.js).

Para tareas largas que no son loops, traduce las líneas más relevantes a UI strings:

```js
function _interpretSessionLogLine(line) {
    if (/Llenando email \+ contraseña/.test(line))   return { title: "Llenando credenciales…", detail: "..." };
    if (/Esperando código OTP por IMAP/.test(line))  return { title: "Esperando el código en el correo…", detail: "..." };
    // ...
    return null;
}
```

Permite que el modal de progreso muestre algo como `"Esperando el código en el correo…"` en vez de `"[AUTO] Esperando código OTP por IMAP..."`.

### 9. Manejo de modales bloqueantes

Bucéfalo a veces muestra modales tipo "AI Builder habilitado" o "Novedades" que tapan los elementos. Patrón:

```python
_DISMISS_BUTTON_SELECTOR = (
    'button:has-text("Entendido"), button:has-text("Got it"), '
    'button:has-text("OK"), button:has-text("Aceptar")'
)

def _dismiss_blocking_modals(scope):
    try:
        btn = scope.locator(_DISMISS_BUTTON_SELECTOR).first
        if btn.count() > 0 and btn.is_visible(timeout=500):
            btn.click()
            time.sleep(0.7)
            return True
    except Exception:
        pass
    return False

# Llamar PREEMPTIVAMENTE antes de tu click — y reintentar si reaparece.
for _ in range(3):
    if not _dismiss_blocking_modals(scope):
        break
# Tu acción:
target.click()
# Y otra vez por si el modal apareció después.
_dismiss_blocking_modals(scope)
```

Si el modal aparece durante un click crítico (toggle de switch), reintentar el click hasta 3 veces verificando el estado deseado.

### 10. Espera explícita por estabilización del estado

Algunos elementos cargan en el DOM antes de tener su estado real bound. Patrón:

```python
# En vez de leer aria-checked inmediatamente:
state = switch.get_attribute("aria-checked")  # ⚠ puede ser el default, no el real

# Esperar networkidle (incluso si caduca, el sleep es útil) + margen:
try:
    builder_frame.wait_for_load_state("networkidle", timeout=25000)
except Exception:
    pass
time.sleep(3)  # GHL termina de bindear el estado real

state = switch.get_attribute("aria-checked")  # ✓ ahora confiable
```

---

## Anti-patterns observados

| Anti-pattern | Por qué falla | Alternativa |
|---|---|---|
| `confirm()` / `prompt()` / `alert()` nativos del browser | Rompen la estética del dashboard | `appConfirm({...})` / `appPrompt({...})` en `static/js/app.js` |
| `input.fill("123456")` en input con `maxlength="1"` | Solo acepta el primer caracter | `keyboard.type(code, delay=80)` con foco previo |
| `if "login" in page.url` como única señal de sesión expirada | Las SPAs no cambian URL | Combinar con `page.locator('input[type="password"]')` o textos visibles |
| `wait_for_selector` rígido a un selector único | Caduca antes de probar variantes | Polling sobre lista de selectores con `time.time() < deadline` |
| Abrir un Chromium nuevo por cada item de un bulk | 8-10 s perdidos por item | Reusar browser/context entre items con loop interno |
| Trust en el toast "Guardado" del UI como confirmación | Bucéfalo tiene bugs visuales | Reconsultar la API tras la mutación |
| Saltar pausa entre items del bulk | GHL rate-limita y gatilla antibot | `time.sleep(2)` o más entre operaciones |

---

## Plantilla recomendada para un script nuevo

Si quieres escribir un script nuevo de browser-automation contra Bucéfalo:

```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Tu descripción aquí."""

import os, sys, time
ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if ROOT_DIR not in sys.path:
    sys.path.insert(0, ROOT_DIR)

# Reusar helpers existentes del manager principal.
from scripts.ghl_browser_workflow_manager import (
    _open_browser, _close_and_save,
    _save_debug_screenshot,
    _any_frame_at_login, SessionExpiredError,
    _INTERRUPT_STATE, _install_signal_handlers,
    ensure_playwright_browsers, session_file_status,
)


def _perform_action_on_page(page, params):
    """Hace lo tuyo en una página ya abierta. Lanza SessionExpiredError si Bucéfalo
    redirige al login. Devuelve True si tuvo éxito."""
    target_url = f"https://crm.bucefalocrm.io/.../{params['id']}"
    page.goto(target_url, wait_until="domcontentloaded", timeout=30000)
    time.sleep(2)
    if _any_frame_at_login(page):
        raise SessionExpiredError("Bucéfalo redirigió al login")

    # ... tu lógica aquí, con screenshots de debug en puntos críticos ...
    _save_debug_screenshot(page, "mi_paso_critico")
    return True


def main():
    _install_signal_handlers()
    if not ensure_playwright_browsers():
        sys.exit(1)
    exists, age = session_file_status()
    if not exists:
        print("ERROR: Falta generated/browser/session.json. Renueva primero con 'Renovar sesión Bucéfalo'.")
        sys.exit(1)

    from playwright.sync_api import sync_playwright
    with sync_playwright() as p:
        browser, context, page = _open_browser(p)
        _INTERRUPT_STATE["browser"] = browser
        _INTERRUPT_STATE["context"] = context
        try:
            ok = _perform_action_on_page(page, {"id": "..."})
            sys.exit(0 if ok else 1)
        finally:
            _close_and_save(browser, context)


if __name__ == "__main__":
    main()
```

Y si el script debe correr desde el dashboard como subprocess, registra su entrada en `SCRIPTS_METADATA` dentro de `script_runner.py`.

---

## Checklist antes de mergear un script nuevo

- [ ] ¿Los selectores tienen variantes en español **y** inglés?
- [ ] ¿Cada paso crítico guarda screenshot con `_save_debug_screenshot`?
- [ ] ¿La detección de sesión expirada usa `_any_frame_at_login` (URL + DOM)?
- [ ] ¿Las mutaciones validan contra la API tras un margen de 3+ segundos con backoff?
- [ ] ¿Hay manejo de `SessionExpiredError` para abortar early en bulks?
- [ ] ¿El browser se cierra con `_close_and_save` (refresca cookies)?
- [ ] ¿El script registra contexto en `_INTERRUPT_STATE` para que las interrupciones cierren limpio?
- [ ] ¿Las pausas entre operaciones son al menos 2 s?
- [ ] ¿Para inputs maxlength=1, se usa `keyboard.type` con delay?
- [ ] ¿Hay un `RESULT:` único por item si es un bulk (para el parser frontend)?
- [ ] ¿`sys.stdout`/`sys.stderr` reconfigurados a UTF-8 al inicio del script? (evita `UnicodeEncodeError` en Windows cp1252 con `→`, `·`, etc.)
- [ ] Si escanea o lee el **editor visual** de workflows: ¿espera el frame correcto (`client-app-automation-workflows.leadconnectorhq.com`, no el principal) y fuerza el render completo con `#workflow-fit-to-screen`?

---

## Caso de estudio 2: el detector de anomalías en el editor visual de workflows

### Problema

Escanear el canvas de cada workflow buscando tres tipos de anomalía: ícono naranja en nodos (`img#pg-actions__icon--eh-show-error`), IDs de custom field sin resolver dentro del texto del nodo (~20 chars alfanuméricos entre comillas), y avisos globales (`span.n-button__content svg[class*="alert"]`).

El primer escaneo reportó **0 anomalías** en un workflow donde el usuario sabía que existía la cadena `If "Ct0n2f9dvjZNe9npY6WY" no está vac...`. Tomó cinco iteraciones llegar a un detector confiable.

### Lecciones aprendidas (orden de iteración)

#### L1 — El editor visual vive en un iframe externo, no en `crm.bucefalocrm.io`

La page principal (`https://crm.bucefalocrm.io/location/{loc}/workflow/{wf}`) contiene un iframe a `https://client-app-automation-workflows.leadconnectorhq.com/...` donde está el builder real.

`_find_builder_frame()` original buscaba el primer frame con un switch o modal del AI Builder, lo cual funciona para toggles pero no garantiza que sea el frame del canvas. Para escanear nodos hay que **elegir el frame con MÁS matches del wrapper de nodos**, no el primero que tenga ≥1.

```python
# Estrategia que funcionó:
best_frame, best_count = None, 0
for frame in page.frames:
    count = frame.locator(NODE_WRAPPER_SEL).count()
    if count > best_count:
        best_count = count
        best_frame = frame
```

Diagnóstico: imprimir `page.frames` con `frame.url` + `count` por frame.

#### L2 — Vue Flow lazy-renderiza nodos: hay que forzar fit-to-screen

El builder de workflows está construido sobre **Vue Flow** (`vue-flow__node`, `vue-flow__minimap`). Vue Flow solo renderiza en el DOM los nodos visibles en el viewport actual — el resto está representado en el minimap pero **no existe como elementos del DOM**.

Sin fit-to-screen, un workflow de 23 nodos puede renderizar solo 2-5 (los visibles al cargar). Con el ícono de "Ajustar a la pantalla" clickeado, los 23 quedan accesibles.

```python
fit_btn = frame.locator('#workflow-fit-to-screen')
if fit_btn.count() > 0:
    fit_btn.first.click(timeout=3000)
    time.sleep(2)
    fit_btn.first.click(timeout=2000)  # doble click: el primero a veces solo muestra tooltip
```

IDs útiles del builder (todos están en el frame del iframe externo):
- `#workflow-fit-to-screen` — encajar todo el flujo. **Imprescindible para escaneo full.**
- `#workflow-zoom-in`, `#workflow-zoom-out`, `#workflow-zoom-value` — controles manuales.
- `#workflow-builder-tab-error-highlight` — tab "Resaltar errores" del builder, candidato para futuras detecciones.
- `#workflow-builder-tab-search-and-replace` — útil para buscar IDs específicos sin escanear el DOM.

#### L3 — Los `data-v-*` son hashes scoped de Vue que cambian con deploys

Inicialmente usé `[data-v-aad7ddfb], [data-v-72bc1535]` como selector porque esos hashes aparecían en el HTML que el usuario me compartió. Esos hashes son IDs internos que Vue genera al compilar — **cambian con cada deploy de GHL**. Selectores robustos por estructura/clase:

- `div.rounded-xl.node-shadow` — wrapper de nodos de acción.
- `div.rounded-xl.border-b-4` — wrapper de nodos Branch (border de color según tipo).
- `div.rounded-xl.nopan` — algunos wrappers.
- `#action-node-container` — id del contenedor (se repite por nodo, pero CSS no lo previene).
- `.vue-flow__node` con `data-id="<uuid>"` — wrapper estable del Vue Flow propio, **es el más confiable para identificar nodos individualmente**.

#### L4 — `inner_text()` respeta CSS `truncate`; usa `text_content()`

Los nodos del builder usan `class="truncate"` (Tailwind) para cortar texto visualmente con ellipsis. `inner_text(timeout=...)` de Playwright **devuelve solo el texto visible** según el CSS rendering — pierde caracteres después del corte.

```python
text = node.text_content(timeout=500) or ""  # devuelve todo el texto del DOM, ignora truncate
```

Esto es crítico para la regex de IDs (`r'["“”]([A-Za-z0-9]{20})["“”]'`) que requiere las dos comillas: con `inner_text()` la comilla de cierre puede quedar oculta tras la elipsis.

#### L5 — Los íconos de alerta están posicionados absolutos FUERA del wrapper del nodo

El ícono `img#pg-actions__icon--eh-show-error` no es descendiente del `vue-flow__node` correspondiente — Vue Flow lo renderiza como overlay con `position: absolute` en un div separado en el DOM. Subir por `parentElement` no llega al nodo.

Solución: **usar `el.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4')`** que busca el ancestor más cercano con cualquiera de esos selectores. Si no encuentra, fallback a buscar el sibling con texto significativo.

```js
let node = img.closest('.vue-flow__node, [data-id], div.rounded-xl.node-shadow, div.rounded-xl.border-b-4, div[class*="rounded-xl"]');
// node.getAttribute('data-id') te da el UUID del nodo Vue Flow.
```

Reportar `data-id` del nodo en el output permite al usuario abrir el nodo específico en la UI (los `data-id` son los UUIDs que GHL usa internamente).

#### L6 — UTF-8 en stdout: la flecha `→` mata el script en Windows cp1252

Cuando el script se corre como subprocess desde el dashboard, su stdout va a un pipe. En Windows el encoding default es `cp1252` y caracteres como `→`, `·`, `«»` rompen con `UnicodeEncodeError`. Reconfigurar al inicio del script:

```python
try:
    sys.stdout.reconfigure(encoding="utf-8")
    sys.stderr.reconfigure(encoding="utf-8")
except Exception:
    # Python <3.7: fallback
    import io
    sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", line_buffering=True)
    sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", line_buffering=True)
```

Esto aplica a **cualquier script Playwright nuevo** porque la consola del subprocess y las capturas SSE pasan por esos pipes.

#### L7 — Para inspeccionar el DOM real, dumpea el HTML del frame correcto

Cuando los selectores no matchean lo que esperas, el primer reflejo es ajustar el selector. El paso anterior es **dumpear el HTML real del frame que estás escaneando** y grepearlo. Patrón:

```python
if os.environ.get("ANOMALY_SCANNER_DEBUG"):
    html = frame.evaluate("() => document.documentElement.outerHTML")
    dump_path = os.path.join(SCREENSHOTS_DIR, f"frame_dump_{wf_id[:8]}.html")
    with open(dump_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"[DEBUG] Frame HTML dump: {dump_path} ({len(html)} chars)")
    # También logguea cuál frame elegiste vs todos los disponibles:
    for fi, f_ in enumerate(page.frames):
        cnt = f_.locator(NODE_WRAPPER_SEL).count()
        print(f"[DEBUG] page.frames[{fi}]: count={cnt} url={f_.url[:120]}")
```

`frame.evaluate("() => document.documentElement.outerHTML")` retorna el HTML **live** (post-JS), mientras que `frame.content()` puede devolver una versión más temprana.

#### L8 — Las anomalías de campo pueden necesitar contexto semántico, no solo regex

La regex `r'["“”]([A-Za-z0-9]{20})["“”]'` matchea 20 chars alfanuméricos entre comillas. Pero esos 20 chars podrían ser el `workflow_id`, el `location_id`, un UUID de pipeline, o un identificador legítimo. Filtros que aplicamos:

- Descartar si el candidato coincide con `workflow_id` o `location_id` del propio workflow.
- Exigir keywords de contexto en el texto del nodo (`"no está vac"`, `"está vac"`, `"is empty"`, `"es igual"`, `"contiene"`, etc.).

Después de la primera corrida `--all`, revisar manualmente 5-10 hits y ajustar `CONTEXT_KEYWORDS` o endurecer la regex.

#### L9 — **Si la app ya valida algo, no lo re-implementes con heurísticas — pídeselo a la app**

El usuario nos mostró que GHL tiene un botón `#workflow-builder-tab-error-highlight` que se pinta naranja cuando el workflow tiene errores. Al clickearlo, se abre un panel con la lista exacta de nodos problemáticos, sus StepIds y descripciones del error generadas por el propio motor de validación de GHL.

Esto es **infinitamente más confiable** que cualquier heurística externa:
- Idioma-agnóstico (el botón existe con el mismo `id` en español y en inglés).
- Robusto a deploys (el `id` es estable).
- Lo que GHL considera "error" es exactamente lo que el usuario considera "error" en la UI.
- Da info estructurada: `node_name + StepId + descripción exacta del error`.

Patrón resultante (lo que ahora es el **detector primario** del scanner):

```python
def _extract_ghl_native_errors(frame):
    btn = frame.locator('#workflow-builder-tab-error-highlight')
    if btn.count() == 0:
        return []
    btn.first.click(timeout=3000)
    time.sleep(2.5)  # esperar render del panel
    return frame.evaluate("""() => {
        const out = [];
        const stepIdRe = /StepId\\s*:?\\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i;
        // Wrapper de cada error: 'flex flex-col gap-2 py-4 border-b border-gray-200'
        const items = document.querySelectorAll('div[class*="border-b"][class*="border-gray-200"][class*="py-4"]');
        const seen = new Set();
        for (const el of items) {
            const txt = (el.textContent || '').replace(/\\s+/g, ' ').trim();
            const m = stepIdRe.exec(txt);
            if (!m || seen.has(m[1])) continue;
            seen.add(m[1]);
            const cleaned = txt.replace(/^\\s*\\d+\\.\\s*/, '');
            const stepIdx = cleaned.toLowerCase().indexOf('stepid');
            out.push({
                node_label: cleaned.slice(0, stepIdx).trim().slice(0, 120),
                step_id: m[1],
                description: cleaned.slice(stepIdx).replace(stepIdRe, '').trim().slice(0, 400),
            });
        }
        return out;
    }""") or []
```

Aplicación general: **antes de escribir heurísticas para detectar X, busca si la app expone un tab/panel/badge que ya cuente lo que necesitas.** En GHL hay varios candidatos similares que pueden servir para otros escenarios:
- `#workflow-builder-tab-error-highlight` — errores del motor de validación (lo anterior).
- `#workflow-builder-tab-stats-view` — estadísticas de ejecución, ideal para auditar workflows con tasas altas de falla.
- `#workflow-builder-tab-version-history` — historial de cambios.
- `#workflow-builder-tab-search-and-replace` — buscar/reemplazar texto en nodos, atajo para encontrar IDs sin escanear DOM completo.

Las heurísticas externas (escanear DOM con selectores y regex) siguen siendo útiles como **complemento** para detectar cosas que la app NO considera error pero el usuario sí — p.ej. nuestro detector heurístico de `unresolved_field_id` encontró `Ct0n2f9dvjZNe9npY6WY` en un nodo Branch que GHL considera sintácticamente válido (porque la condición `If "X" no está vacío` se evalúa OK como texto literal) pero que para el negocio es un bug porque ese ID nunca se resolvió al nombre del campo.

**Política recomendada para el reporte**:
- La detección nativa de la app es la **fuente primaria**: si reporta un nodo, ese nodo se incluye con info rica (descripción del error oficial).
- Las heurísticas son **complemento**: corren igual, pero si un hallazgo heurístico apunta al MISMO nodo (por `data-id`/StepId) que un hallazgo nativo, se suprime el heurístico para evitar ruido (`if node_id in native_step_ids: continue`).
- Hallazgos heurísticos en nodos NO cubiertos por la app se mantienen — es exactamente donde aportan valor.

### Integración con el dashboard de bulk-operations

Para que el progreso del escaneo se renderice solo en la bulk-bar existente (Publicar/Borrador/Eliminar), el script tiene que emitir exactamente las líneas que `app.js` parsea:

```
[BULK X/N] === 'workflow_name' (workflow_id) en location_id ===
[BULK X/N] RESULT: success|failed|skipped
=== RESUMEN BULK-<TARGET> ===
```

El frontend hace `await mutateFetch(\`/api/workflows/bulk-${target}\`, ...)` con target en `{draft, publish, delete, scan-anomalies}`. Para sumar una operación nueva basta con:

1. Endpoint `POST /api/workflows/bulk-{target}` que escribe batch JSON y dispara el script.
2. Script que acepte `--batch-file <path>` y emita las líneas BULK.
3. Botón en la bulk-bar + función JS que llame `_executeBulkOperation('{target}', items, label)`.

## Lección 19. Paralelismo conservador: N browsers, 1 sesión compartida

**Problema:** los bulks Playwright procesaban workflows uno por uno (~30-50s c/u). Un scan/bulk completo sobre las 50 cuentas tomaba horas.

**Intento fallido (no replicar):** "1 proceso → 1 `Browser` → N `BrowserContext` en threads" — la sync API de Playwright lleva un **greenlet event loop por thread**; cualquier objeto creado en el thread main (`Browser`, `Context`, `Page`) explota al usarse desde otro thread con:
```
greenlet.error: cannot switch to a different thread (which happens to have exited)
```

**Solución que funciona:** cada worker thread abre su **propio** `sync_playwright().start()` + `browser.launch()` + `new_context(storage_state=SESSION_FILE)`. La sesión sí se comparte (1 login → N browsers reusando cookies), pero cada thread tiene su event loop dedicado.

Implementación en `ghl_browser_workflow_manager._run_parallel_bulk` y `ghl_browser_workflow_anomaly_scanner.scan_workflows_parallel`.

**Topología real:**
```
1 proceso → N threads (ThreadPoolExecutor)
            │
            └── cada thread: sync_playwright().start()
                              └── chromium.launch()           ← N browsers Chromium
                                   └── new_context(storage_state=SESSION_FILE)
                                        └── new_page()
```

**Reglas duras:**

1. **NUNCA paralelizar con perfil persistente** (`GHL_BROWSER_PROFILE_DIR`). Chromium bloquea el profile dir → segundo browser que intenta abrirlo cuelga. Detectar al inicio y forzar `workers=1`.
2. **NUNCA paralelizar el login.** OTP por IMAP es un buzón compartido — 2 procesos compitiendo por el mismo correo se pisan. Genera 1 sesión secuencial, todos los workers reusan `session.json`.
3. **Cada thread crea su propio `sync_playwright().start()` + `browser`.** No intentes compartir un Browser entre threads — la sync API tiene un greenlet loop por thread y los objetos están atados al loop que los creó.
4. **`storage_state` se persiste best-effort al cerrar cada context.** Cookies refrescadas por GHL durante el run quedan en `session.json` para la próxima ejecución. No es transaccional; con N workers, gana el último.
5. **Cancelación cooperativa con `threading.Event`.** Cuando un worker captura `SessionExpiredError`, setea `cancel_event`; los demás revisan en el bucle y salen limpio. No swallow de excepciones.
6. **Pausa entre items, no entre workers.** `per_action_pause` (3s default mutador, 1s read-only) por worker, interrumpible mediante chequeo de `cancel_event` en sleep granular (`step=0.25s`).
7. **Agrupar la queue por `location_id`.** Reduce navegación lateral y refresh de cookies cross-sucursal.
8. **Cada cambio del mutador sigue auditándose con `script_audit`.** SQLite serializa writes; activamos WAL en `init_audit_db` para evitar `database is locked` con 2-5 workers.
9. **Tope de 5 workers.** Más allá, el cuello deja de ser CPU/browser y empieza a ser anti-bot/throttling de Bucéfalo. La ganancia plana se vuelve riesgo.
10. **Métricas en `generated/logs/parallel_runs.jsonl`** (append). Comparar `duration_s` y `failed/skipped` entre runs para detectar regresiones o señal de throttling.

**Cuándo subir workers:**
- Read-only (scanner): hasta 5 sin problema.
- Mutador (manager bulk-draft/publish/delete): empezar en 2, máximo 3-4. Cada acción dispara API validate post-mutación.
- Si ves `[METRICS] failed` creciendo o `SESIÓN EXPIRADA` en bulks que antes pasaban → bajar workers, subir `--action-pause`.

**CLI:**
```bash
# scanner read-only, agresivo
python scripts/ghl_browser_workflow_anomaly_scanner.py --all --workers 4 --action-pause 1

# bulk-draft conservador
python scripts/ghl_browser_workflow_manager.py --action bulk-draft \
    --batch-file generated/runtime/batch/draft_batch.json --workers 2 --action-pause 3
```

**Verificación:** ver `[METRICS] start` / `[METRICS] end` en stdout. Run a workers=1 como baseline, luego workers=N, comparar `rate` y `failed`.

**Resultados medidos (2026-05-28, scanner read-only sobre Bucéfalo):**

| Workers | Items | Tiempo | s/item | Speedup |
|---------|-------|--------|--------|---------|
| 1       | 6     | 312.9s | 52.2   | baseline |
| 2       | 6     | 192.8s | 32.1   | **1.62×** |
| 4       | 12    | 200.9s | 16.7   | **3.13×** (vs baseline extrapolado) |

No hubo `SessionExpiredError` ni fallos en ninguna iteración. Cada browser extra consume ~150-200 MB RAM en headless; con 4 workers son ~800 MB-1 GB adicionales — cómodo en una máquina con 8 GB+.

Las acciones por fila individual reusan el mismo flujo con `items = [oneItem]` — no hace falta endpoint separado.

## Lección 20. Auditoría granular paralela + observabilidad

El manager Playwright **registra cada mutación en `script_audit`** siguiendo el patrón gold-standard de `sync_missing_opps_to_brand.py`:

```python
# antes del intento
change_id = script_audit.record_change(run_id, location_id, "workflow", wf_id,
    field_id="status", field_name="status",
    old_value=current, new_value=target)   # status = "planned"
# tras la mutación + validación API
script_audit.mark_change(change_id, "applied")        # éxito
# o
script_audit.mark_change(change_id, "failed", msg)    # cualquier path de error
```

Inyectado en `_perform_toggle_item`, `_perform_delete_item`, y `rename_via_playwright_dom`. Thread-safe (SQLite con WAL + busy_timeout=30s; nuevas conexiones por llamada via `script_audit.get_conn`).

**Bootstrap idempotente**: tanto manager como scanner llaman `script_audit.create_run(...)` (que usa `INSERT OR IGNORE`) al inicio de `main()`. Esto garantiza que runs lanzados desde CLI (sin pasar por el dashboard) tampoco queden huérfanos.

**Log estructurado por-run**: `script_logger.RunLogger` (en raíz) escribe una línea JSONL por evento a `generated/logs/script_runs/{run_id}.jsonl`. Cada línea: `{ts, level, run_id, event, worker_id, location_id, workflow_id, status?, change_id?, error_id?, duration_ms?}`. Thread-safe (lock interno) — los N workers paralelos escriben sin race.

### Las 4 fuentes de verdad para forensics

| Síntoma / pregunta | Dónde mirar |
|---|---|
| ¿Qué se intentó/aplicó? | `script_audit.get_run_summary(run_id)` o `python scripts/audit_run.py <run_id>` |
| Bulk falló a la mitad | `generated/logs/script_runs/{run_id}.jsonl` (cronología por worker) |
| Error específico de Playwright | `generated/logs/errors.jsonl` filtrar por `error_id` o por `context.run_id` |
| Performance / speedup | `generated/logs/parallel_runs.jsonl` |
| Estado visual al fallar | `generated/browser/screenshots/*_{loc}_{wf}.png` |
| Listado rápido de runs recientes | `python scripts/audit_run.py --list 20` |

### CLI de inspección — `scripts/audit_run.py`

```bash
python scripts/audit_run.py <run_id>           # resumen + cambios + eventos + errores
python scripts/audit_run.py --list 20          # últimos 20 runs con counts
python scripts/audit_run.py <run_id> --json    # JSON crudo (para jq/pipes)
python scripts/audit_run.py <run_id> --events 50   # más eventos del JSONL
```

Combina las 4 fuentes en una sola vista. No depende del dashboard ni de FastAPI.

### Sobre rollback

`script_audit.rollback_run` revierte mutaciones de **custom fields** via PUT. Las mutaciones DOM de workflows (toggle/delete/rename) **se registran pero no son auto-revertibles** — el undo manual es ejecutar el bulk inverso:

| Acción original | Reversión |
|---|---|
| bulk-draft | bulk-publish del mismo batch |
| bulk-publish | bulk-draft del mismo batch |
| bulk-delete | recrear (no recuperable desde GHL) — confirmar antes de aplicar |
| rename | rename con el old_value que guardó `script_change_log` |

Eso queda documentado y trazable; ningún cambio se pierde en el éter.
