feat(browser): auto-commit con 3 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-26 23:30:35 +02:00
parent d7387d9d2c
commit 2ff111bae4
3 changed files with 74 additions and 34 deletions
@@ -74,10 +74,16 @@ usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat
Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton Hay que desbloquearlo primero (teclear el password en `input[type=password]` + boton
"Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale "Desbloquear"). Sintoma: `whatsapp_open_chat` devuelve `opened: False` y la lista lateral sale
vacia aunque la sesion siga logueada. vacia aunque la sesion siga logueada.
- **El input vivo solo existe tras pulsar "Adjuntar".** Por eso la funcion hace el click real en - **El menu "Adjuntar" es un TOGGLE (`aria-expanded`).** El `<input type=file>` solo queda "vivo"
el boton "Adjuntar" antes de `setFileInputFiles`; asignar al input persistente decoy no abre el mientras el menu esta ABIERTO; asignar al input con el menu cerrado es un decoy (no abre preview).
preview. La WhatsApp Web actual usa una **bandeja de medios INLINE compacta** sobre el composer Clickar "Adjuntar" cuando YA esta abierto lo CIERRA. Por eso la funcion clicka solo si
(no un drawer a pantalla completa). `aria-expanded != "true"` y reintenta hasta verlo abierto (no un click ciego). La WhatsApp Web
actual usa una **bandeja de medios INLINE compacta** sobre el composer (no un drawer a pantalla
completa).
- **El envio se verifica por la ultima fila de `#main`, NO por contar filas.** Las filas de `#main`
se VIRTUALIZAN (las antiguas se desmontan al llegar nuevas), asi que el total se mantiene casi
constante. La funcion confirma el envio comprobando que la bandeja se vacio (adjuntos=0) Y que la
ultima fila renderizada es ya una imagen (`img[src^="blob:"]`).
- **El caption NO se embebe en la imagen: viaja como mensaje de texto de seguimiento.** En esta - **El caption NO se embebe en la imagen: viaja como mensaje de texto de seguimiento.** En esta
WhatsApp Web compacta hay dos botones de envio cuando hay media: "Enviar N seleccionados" (envia WhatsApp Web compacta hay dos botones de envio cuando hay media: "Enviar N seleccionados" (envia
la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte, la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte,
+47 -21
View File
@@ -11,15 +11,15 @@ anti-envio-al-contacto-equivocado:
1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con 1. Abre el chat por su nombre exacto (`open_first=True`). Si no abre, aborta. Con
`open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer `open_first=False`, asume el chat abierto pero VERIFICA que el aria-label del composer
contiene el nombre; si no, aborta por seguridad. contiene el nombre; si no, aborta por seguridad.
2. Hace click de raton real en el boton "Adjuntar" del footer: esto expone el 2. ASEGURA que el menu "Adjuntar" esta ABIERTO (es un toggle `aria-expanded`: clickar
`<input type=file>` "vivo" que escucha la SPA (antes de pulsarlo el input persistente cuando ya esta abierto lo cierra). Solo entonces el `<input type=file>` queda "vivo".
no dispara el preview).
3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la 3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la
imagen aparece como miniatura en la bandeja inline. imagen aparece como miniatura en la bandeja inline.
4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click 4. Espera a que la bandeja aparezca (boton "Quitar archivo adjunto" presente) y hace click
real en el boton de enviar la bandeja (icono `wds-ic-send-filled`); verifica que la real en el boton de enviar la bandeja (icono `wds-ic-send-filled`).
bandeja se cerro (sin adjuntos) para confirmar el envio de la imagen. 5. Verifica el envio comprobando que la bandeja se cerro Y que la ultima fila de `#main`
5. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via es ahora una imagen (las filas se virtualizan, asi que NO sirve contar filas).
6. Si `caption` no esta vacio, lo envia como un MENSAJE DE TEXTO de seguimiento via
`whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la `whatsapp_send_message` (con `open_first=False`, el chat ya esta abierto). En la
WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de
forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption]. forma fiable, asi que la descripcion viaja como una segunda burbuja: [imagen][caption].
@@ -54,6 +54,16 @@ def _center(expr: str, port: int, substr: str):
return None return None
def _adj_expanded(port: int, substr: str) -> str:
"""Estado aria-expanded del boton 'Adjuntar' ('true'/'false'/'no-btn')."""
r = cdp_eval(
'/*ADJEXP*/var e=document.querySelector(\'button[aria-label="Adjuntar"]\'); '
"e?e.getAttribute('aria-expanded'):'no-btn'",
port=port, target_url_substr=substr,
)
return r.get("value") or "no-btn"
def _attachment_count(port: int, substr: str) -> int: def _attachment_count(port: int, substr: str) -> int:
"""Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto').""" """Numero de adjuntos en la bandeja inline (botones 'Quitar archivo adjunto')."""
r = cdp_eval( r = cdp_eval(
@@ -64,6 +74,16 @@ def _attachment_count(port: int, substr: str) -> int:
return v if isinstance(v, int) else 0 return v if isinstance(v, int) else 0
def _last_row_is_image(port: int, substr: str) -> bool:
"""True si la ultima fila renderizada de #main contiene una imagen (blob)."""
r = cdp_eval(
'/*LASTIMG*/(() => {const r=[...document.querySelectorAll(\'#main [role="row"]\')]'
".slice(-1)[0]; return r?!!r.querySelector('img[src^=\"blob:\"]'):false;})()",
port=port, target_url_substr=substr,
)
return bool(r.get("value"))
def whatsapp_send_image( def whatsapp_send_image(
name: str, name: str,
image_path: str, image_path: str,
@@ -131,7 +151,9 @@ def whatsapp_send_image(
if name not in (chk.get("value") or ""): if name not in (chk.get("value") or ""):
return fail("el chat abierto no coincide con el destinatario; abortado por seguridad") return fail("el chat abierto no coincide con el destinatario; abortado por seguridad")
# 2. Click real en "Adjuntar" para exponer el <input type=file> vivo. # 2. Asegurar el menu "Adjuntar" ABIERTO. Es un TOGGLE (aria-expanded): clickar cuando
# ya esta abierto lo cierra y el input vivo desaparece. Por eso clickamos SOLO si no
# esta expandido y reintentamos hasta verlo abierto.
adj = _center( adj = _center(
'/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');' '/*ADJUNTAR*/(() => {const e=document.querySelector(\'button[aria-label="Adjuntar"]\');'
"if(!e)return null;const b=e.getBoundingClientRect();" "if(!e)return null;const b=e.getBoundingClientRect();"
@@ -140,21 +162,23 @@ def whatsapp_send_image(
) )
if not adj: if not adj:
return fail("boton 'Adjuntar' no encontrado en el footer") return fail("boton 'Adjuntar' no encontrado en el footer")
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S) for _ in range(3):
time.sleep(0.8) if _adj_expanded(port, S) == "true":
break
cdp_click_xy(adj["x"], adj["y"], port=port, target_url_substr=S)
time.sleep(0.6)
if _adj_expanded(port, S) != "true":
return fail("no se pudo abrir el menu 'Adjuntar' (aria-expanded sigue en false)")
# 3. Asignar la imagen al input multiple (el que se activa tras Adjuntar); # 3. Asignar la imagen al primer <input type=file> (el "vivo" mientras el menu esta
# fallback al primer input file si el selector con [multiple] no resuelve. # abierto). El composer queda VACIO, asi que luego el unico wds-ic-send-filled es el
r = cdp_set_file_input('input[type="file"][multiple]', abs_img, # de enviar la bandeja.
r = cdp_set_file_input('input[type="file"]', abs_img,
port=port, target_url_substr=S) port=port, target_url_substr=S)
if not r.get("ok"):
r = cdp_set_file_input('input[type="file"]', abs_img,
port=port, target_url_substr=S)
if not r.get("ok"): if not r.get("ok"):
return fail("no se pudo adjuntar la imagen: " + r.get("error", "")) return fail("no se pudo adjuntar la imagen: " + r.get("error", ""))
# 4. Esperar a que la bandeja aparezca (adjunto presente). El composer queda VACIO, # 4. Esperar a que la bandeja aparezca (adjunto presente).
# asi que el unico boton wds-ic-send-filled es el de enviar la bandeja.
attached = False attached = False
for _ in range(15): for _ in range(15):
time.sleep(0.2) time.sleep(0.2)
@@ -163,6 +187,7 @@ def whatsapp_send_image(
break break
if not attached: if not attached:
return fail("el preview no aparecio tras adjuntar la imagen") return fail("el preview no aparecio tras adjuntar la imagen")
time.sleep(0.3)
# 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled). # 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled).
snd = _center( snd = _center(
@@ -176,15 +201,16 @@ def whatsapp_send_image(
return fail("boton de enviar (wds-ic-send-filled) no encontrado") return fail("boton de enviar (wds-ic-send-filled) no encontrado")
cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S) cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S)
# 6. Verificar que la bandeja se cerro (sin adjuntos) -> imagen enviada. # 6. Confirmar envio: la bandeja se cierra (adjuntos=0) Y la ultima fila de #main es ya
# una imagen. Las filas de #main se VIRTUALIZAN, asi que contar filas no sirve.
image_sent = False image_sent = False
for _ in range(15): for _ in range(20):
time.sleep(0.2) time.sleep(0.2)
if _attachment_count(port, S) == 0: if _attachment_count(port, S) == 0 and _last_row_is_image(port, S):
image_sent = True image_sent = True
break break
if not image_sent: if not image_sent:
return fail("la bandeja no se cerro tras pulsar enviar; envio incierto") return fail("no se confirmo la imagen en el chat tras pulsar enviar; envio incierto")
# 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja). # 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja).
caption_sent = False caption_sent = False
@@ -7,9 +7,10 @@ modulo `browser.whatsapp_send_image` (donde quedan ligados los nombres por el
`from browser.X import Y`), de modo que NO hace falta Chrome. `from browser.X import Y`), de modo que NO hace falta Chrome.
El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en El fake de cdp_eval distingue cada expresion por un marcador de comentario JS embebido en
ella (`/*ADJUNTAR*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LABEL*/`). El estado `after_send` (que ella (`/*ADJUNTAR*/`, `/*ADJEXP*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LASTIMG*/`, `/*LABEL*/`).
/*SEND*/ activa) hace que /*PREVIEW*/ devuelva 0 adjuntos tras el envio de la imagen, El estado simula el ciclo real: el menu Adjuntar arranca cerrado y se abre tras un click;
simulando el cierre de la bandeja. tras pulsar enviar (/*SEND*/) la bandeja se vacia (/*PREVIEW*/ -> 0) y la ultima fila pasa a
ser una imagen (/*LASTIMG*/ -> True).
""" """
import json import json
@@ -41,11 +42,17 @@ def _make_eval(label="Escribir un mensaje para el grupo NOTAS WASAP", state=None
def fake(expr, *, port=9222, target_url_substr=""): def fake(expr, *, port=9222, target_url_substr=""):
if "/*ADJUNTAR*/" in expr: if "/*ADJUNTAR*/" in expr:
return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""} return {"ok": True, "value": json.dumps({"x": 575, "y": 589}), "error": ""}
if "/*ADJEXP*/" in expr:
n = state.get("adjexp_n", 0)
state["adjexp_n"] = n + 1
return {"ok": True, "value": ("false" if n == 0 else "true"), "error": ""}
if "/*SEND*/" in expr: if "/*SEND*/" in expr:
state["after_send"] = True state["after_send"] = True
return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""} return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""}
if "/*PREVIEW*/" in expr: if "/*PREVIEW*/" in expr:
return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""} return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""}
if "/*LASTIMG*/" in expr:
return {"ok": True, "value": bool(state.get("after_send")), "error": ""}
if "/*LABEL*/" in expr: if "/*LABEL*/" in expr:
return {"ok": True, "value": label, "error": ""} return {"ok": True, "value": label, "error": ""}
return {"ok": True, "value": None, "error": ""} return {"ok": True, "value": None, "error": ""}
@@ -83,10 +90,11 @@ def test_golden_envia_imagen_y_caption_de_seguimiento(monkeypatch):
assert res["image"] == _IMG assert res["image"] == _IMG
assert res["caption"] == cap assert res["caption"] == cap
assert res["error"] == "" assert res["error"] == ""
# Se adjunto la imagen (ruta absoluta) por setFileInputFiles. # Se adjunto la imagen (ruta absoluta) por setFileInputFiles, una sola vez.
assert set_spy.calls[0][0][0] == 'input[type="file"][multiple]' assert len(set_spy.calls) == 1
assert set_spy.calls[0][0][0] == 'input[type="file"]'
assert set_spy.calls[0][0][1] == _IMG assert set_spy.calls[0][0][1] == _IMG
# Dos clicks reales: Adjuntar y Enviar (bandeja). # Dos clicks reales: abrir Adjuntar (menu cerrado al inicio) y Enviar.
assert len(click_spy.calls) == 2 assert len(click_spy.calls) == 2
# El caption viaja como mensaje de texto de seguimiento, open_first=False. # El caption viaja como mensaje de texto de seguimiento, open_first=False.
assert len(sendmsg_spy.calls) == 1 assert len(sendmsg_spy.calls) == 1
@@ -107,7 +115,7 @@ def test_envia_sin_caption_no_manda_texto(monkeypatch):
assert res["caption_sent"] is False assert res["caption_sent"] is False
# Sin caption: NO se manda mensaje de texto de seguimiento. # Sin caption: NO se manda mensaje de texto de seguimiento.
assert len(sendmsg_spy.calls) == 0 assert len(sendmsg_spy.calls) == 0
assert len(click_spy.calls) == 2 assert len(set_spy.calls) == 1
def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch): def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch):
@@ -167,7 +175,7 @@ def test_error_set_file_input_falla_no_envia(monkeypatch):
assert res["sent"] is False assert res["sent"] is False
assert "no se pudo adjuntar" in res["error"] assert "no se pudo adjuntar" in res["error"]
# Se intento adjuntar (dos selectores) pero no se llego a enviar (solo el click de Adjuntar). # Se abrio Adjuntar (1 click) e intento adjuntar (1 set), pero NO se envio nada.
assert len(set_spy.calls) == 2 assert len(set_spy.calls) == 1
assert len(click_spy.calls) == 1 assert len(click_spy.calls) == 1
assert len(sendmsg_spy.calls) == 0 assert len(sendmsg_spy.calls) == 0