diff --git a/python/functions/browser/whatsapp_send_image.md b/python/functions/browser/whatsapp_send_image.md index 25be405a..84d046f5 100644 --- a/python/functions/browser/whatsapp_send_image.md +++ b/python/functions/browser/whatsapp_send_image.md @@ -6,9 +6,9 @@ domain: browser version: "1.0.0" purity: impure signature: "def whatsapp_send_image(name: str, image_path: str, *, caption: str = '', port: int = 9222, target_url_substr: str = 'whatsapp', open_first: bool = True) -> dict" -description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera el preview de la bandeja inline, teclea el caption opcional con teclado CDP real, y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Accion con efecto: envia la imagen DE VERDAD, no reversible." +description: "Envia una imagen (con caption opcional) a un chat de WhatsApp Web en una pestana ya logueada del navegador diario via CDP, sin abrir ventana nueva ni darle foco. Abre el chat por nombre exacto (whatsapp_open_chat) y verifica el destinatario (salvaguarda anti-envio-equivocado), hace click real en 'Adjuntar' para exponer el vivo, asigna la imagen con cdp_set_file_input (DOM.setFileInputFiles), espera la bandeja inline y hace click en el boton enviar (icono wds-ic-send-filled) verificando que la bandeja se cerro. Si hay caption, lo envia como mensaje de texto de seguimiento via whatsapp_send_message (en la WhatsApp Web compacta actual el caption embebido en la imagen no es automatizable de forma fiable, asi que viaja como segunda burbuja [imagen][caption]). Accion con efecto: envia la imagen DE VERDAD, no reversible." tags: [whatsapp, cdp, browser, automation, image, upload, python, navegator] -uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_type_chars_py_browser, cdp_set_file_input_py_browser] +uses_functions: [whatsapp_open_chat_py_browser, cdp_eval_py_browser, cdp_click_xy_py_browser, cdp_set_file_input_py_browser, whatsapp_send_message_py_browser] uses_types: [] returns: [] returns_optional: false @@ -21,16 +21,16 @@ params_schema: - name: image_path desc: "Ruta de la imagen a enviar. Se expande (~) y se convierte a ruta ABSOLUTA; debe existir en disco o aborta con error sin abrir el chat." - name: caption - desc: "Texto opcional que acompana la imagen. Se teclea en el composer con teclado CDP real caracter a caracter; '' (default) envia la imagen sin caption." + desc: "Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento (segunda burbuja [imagen][caption]) via whatsapp_send_message; '' (default) envia solo la imagen. La WhatsApp Web compacta actual no permite automatizar el caption embebido en la imagen de forma fiable." - name: port desc: "Puerto de remote debugging de Chrome. Default 9222." - name: target_url_substr desc: "Substring que debe contener la URL del target (pestana). Default 'whatsapp'." - name: open_first desc: "Si True (default), abre el chat por su nombre antes de adjuntar. Si False, asume el chat ya abierto pero verifica el aria-label del composer contra name (aborta si no coincide)." - output: "dict {ok: bool (alias de sent), sent: bool, recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si sent=True)}. sent=True solo si la imagen se adjunto, el caption (si lo hay) se verifico y se pulso enviar dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'." + output: "dict {ok: bool (imagen + caption enviados), sent: bool (imagen enviada), caption_sent: bool (caption de seguimiento enviado, False si no habia o fallo), recipient: str, image: str (ruta absoluta), caption: str, error: str (motivo del fallo, vacio si todo ok)}. sent=True solo si la imagen se adjunto y se envio dejando la bandeja vacia. Nunca lanza: los fallos se reportan en 'sent'/'ok' + 'error'." tested: true -tests: ["test_golden_adjunta_caption_y_envia", "test_envia_sin_caption_no_teclea", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"] +tests: ["test_golden_envia_imagen_y_caption_de_seguimiento", "test_envia_sin_caption_no_manda_texto", "test_edge_imagen_no_existe_error_sin_abrir", "test_edge_open_fallido_error_sin_adjuntar", "test_seguridad_open_first_false_label_no_coincide_aborta", "test_error_set_file_input_falla_no_envia"] test_file_path: "python/functions/browser/whatsapp_send_image_test.py" file_path: "python/functions/browser/whatsapp_send_image.py" --- @@ -76,8 +76,15 @@ usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat vacia aunque la sesion siga logueada. - **El input vivo solo existe tras pulsar "Adjuntar".** Por eso la funcion hace el click real en el boton "Adjuntar" antes de `setFileInputFiles`; asignar al input persistente decoy no abre el - preview. La WhatsApp Web actual usa una **bandeja de medios INLINE** sobre el composer (no un - drawer a pantalla completa): el caption se escribe en el MISMO composer del footer. + preview. La WhatsApp Web actual usa una **bandeja de medios INLINE compacta** sobre el composer + (no un drawer a pantalla completa). +- **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 + la bandeja, IGNORA el texto del composer) y "Enviar"/Enter (envia el texto como burbuja aparte, + descartando la media en cola). No hay un campo de caption por-imagen automatizable de forma + fiable. Por eso la funcion envia primero la imagen (boton de la bandeja) y, si hay `caption`, lo + manda despues como mensaje de texto via `whatsapp_send_message` (`open_first=False`): el resultado + es [imagen][caption] como dos burbujas. `caption_sent` indica si esa segunda burbuja salio. - **Selector de aria-label en espanol.** El preview se detecta por `[aria-label="Quitar archivo adjunto"]` y el boton de adjuntar por `[aria-label="Adjuntar"]`: dependen del idioma de la UI (espanol). En otro locale habria que ajustar los aria-labels. @@ -87,8 +94,6 @@ usa `whatsapp_send_message`; para leer/confirmar lo enviado, `whatsapp_read_chat `sent=False` con "envio incierto". - **Salvaguarda anti-destinatario-equivocado**: con `open_first=True` abre y verifica el chat; con `open_first=False` lee el aria-label del composer y aborta si no contiene `name`. -- **Caption verificado**: tras teclear, re-lee el `innerText` del composer y solo envia si coincide - EXACTAMENTE con `caption`; si no, devuelve `sent=False` sin enviar. - **Funciona con la ventana minimizada o sin foco**: CDP opera la pestana sin traerla a primer plano. - **Viola los ToS de WhatsApp**: automatizar la web tiene riesgo de ban del numero personal. Usar con cautela y bajo tu responsabilidad. diff --git a/python/functions/browser/whatsapp_send_image.py b/python/functions/browser/whatsapp_send_image.py index 81f60a3e..279b26ca 100644 --- a/python/functions/browser/whatsapp_send_image.py +++ b/python/functions/browser/whatsapp_send_image.py @@ -1,8 +1,9 @@ """Envia una imagen (con caption opcional) a un chat de WhatsApp Web via Chrome DevTools Protocol. Compone `whatsapp_open_chat` (abrir + verificar destinatario) con primitivas CDP del -registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`, `cdp_type_chars`) para adjuntar -y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al sistema. +registry (`cdp_eval`, `cdp_click_xy`, `cdp_set_file_input`) y `whatsapp_send_message` para +adjuntar y enviar una imagen a un contacto/grupo SIN abrir ventana nueva ni darle foco al +sistema. Flujo (modelo de bandeja de medios INLINE de la WhatsApp Web actual), con salvaguarda anti-envio-al-contacto-equivocado: @@ -14,15 +15,17 @@ anti-envio-al-contacto-equivocado: `` "vivo" que escucha la SPA (antes de pulsarlo el input persistente no dispara el preview). 3. Asigna la imagen al input via `cdp_set_file_input` (`DOM.setFileInputFiles`): la - imagen aparece como miniatura en la bandeja inline sobre el composer. - 4. Espera a que el preview aparezca (boton "Quitar archivo adjunto" presente). - 5. Si `caption` no esta vacio, enfoca el composer del footer y teclea el texto con - teclado CDP real (`cdp_type_chars`); verifica el `innerText` antes de enviar. - 6. Click de raton real en el boton enviar (icono `wds-ic-send-filled`) y verifica que la - bandeja se cerro (sin adjuntos) para confirmar el envio. + imagen aparece como miniatura en la bandeja inline. + 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 + bandeja se cerro (sin adjuntos) para confirmar el envio de la imagen. + 5. 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 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]. Validado contra WhatsApp Web real. Accion CON EFECTO REAL E IRREVERSIBLE: envia la imagen -de verdad. +(y el caption) de verdad. """ import json @@ -34,9 +37,9 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from browser.cdp_eval import cdp_eval from browser.cdp_click_xy import cdp_click_xy -from browser.cdp_type_chars import cdp_type_chars from browser.cdp_set_file_input import cdp_set_file_input from browser.whatsapp_open_chat import whatsapp_open_chat +from browser.whatsapp_send_message import whatsapp_send_message def _center(expr: str, port: int, substr: str): @@ -70,7 +73,7 @@ def whatsapp_send_image( target_url_substr: str = "whatsapp", open_first: bool = True, ) -> dict: - """Envia una imagen (con caption opcional) a un chat de WhatsApp Web ya logueado. + """Envia una imagen (con caption opcional de seguimiento) a un chat de WhatsApp Web. Accion CON EFECTO: envia la imagen DE VERDAD (no reversible). Verifica `name`. @@ -80,8 +83,9 @@ def whatsapp_send_image( al destinatario correcto antes de adjuntar. image_path: Ruta de la imagen a enviar. Se expande (`~`) y se convierte a ruta ABSOLUTA; debe existir en disco. - caption: Texto opcional que acompana la imagen. Se teclea en el composer con teclado - CDP real; "" (default) envia la imagen sin caption. + caption: Texto opcional descriptivo. Se envia como un MENSAJE DE TEXTO de seguimiento + (segunda burbuja [imagen][caption]) via `whatsapp_send_message`; "" (default) + envia solo la imagen. port: Puerto de remote debugging de Chrome. Default 9222. target_url_substr: Substring que debe contener la URL del target (pestana). Default "whatsapp". @@ -91,12 +95,14 @@ def whatsapp_send_image( Returns: dict con claves: - ok: bool — alias de sent (True si la imagen se envio). - sent: bool — True si la imagen se adjunto, (caption escrito) y se envio. + ok: bool — True si la imagen se envio y (si habia caption) el caption tambien. + sent: bool — True si la IMAGEN se envio. + caption_sent: bool — True si el caption de seguimiento se envio (False si no + habia caption o si fallo). recipient: str — el nombre solicitado. image: str — ruta absoluta de la imagen. caption: str — caption solicitado. - error: str — motivo del fallo (vacio si sent=True). + error: str — motivo del fallo (vacio si todo ok). Nunca lanza: los fallos se reportan en "sent"/"ok" + "error". """ @@ -104,7 +110,7 @@ def whatsapp_send_image( abs_img = os.path.abspath(os.path.expanduser(image_path)) def fail(error: str) -> dict: - return {"ok": False, "sent": False, "recipient": name, + return {"ok": False, "sent": False, "caption_sent": False, "recipient": name, "image": abs_img, "caption": caption, "error": error} # 0. La imagen debe existir. @@ -147,7 +153,8 @@ def whatsapp_send_image( if not r.get("ok"): return fail("no se pudo adjuntar la imagen: " + r.get("error", "")) - # 4. Esperar a que el preview/bandeja aparezca (adjunto presente). + # 4. Esperar a que la bandeja aparezca (adjunto presente). El composer queda VACIO, + # asi que el unico boton wds-ic-send-filled es el de enviar la bandeja. attached = False for _ in range(15): time.sleep(0.2) @@ -157,27 +164,7 @@ def whatsapp_send_image( if not attached: return fail("el preview no aparecio tras adjuntar la imagen") - # 5. Caption opcional: enfocar el composer y teclear con teclado real. - if caption: - cdp_eval( - '/*FOCUS*/var b=document.querySelector(\'div[contenteditable="true"]\'); ' - "if(b){b.focus();}", - port=port, target_url_substr=S, - ) - time.sleep(0.25) - cdp_type_chars(caption, port=port, target_url_substr=S, delay_ms=15) - time.sleep(0.3) - chk = cdp_eval( - '/*COMPOSER*/var b=document.querySelector(\'div[contenteditable="true"]\'); ' - "b?b.innerText.replace(/\\n/g,''):''", - port=port, target_url_substr=S, - ) - composer = chk.get("value") or "" - if composer != caption: - return fail("el composer no contiene el caption esperado (no enviado): " - + repr(composer)) - - # 6. Click real en el boton enviar (icono wds-ic-send-filled). + # 5. Click real en el boton de enviar la bandeja (icono wds-ic-send-filled). snd = _center( '/*SEND*/(() => {const e=document.querySelector(\'span[data-icon="wds-ic-send-filled"]\');' "if(!e)return null;const b=e.getBoundingClientRect();" @@ -189,14 +176,29 @@ def whatsapp_send_image( return fail("boton de enviar (wds-ic-send-filled) no encontrado") cdp_click_xy(snd["x"], snd["y"], port=port, target_url_substr=S) - # 7. Verificar que la bandeja se cerro (sin adjuntos) -> envio confirmado. + # 6. Verificar que la bandeja se cerro (sin adjuntos) -> imagen enviada. + image_sent = False for _ in range(15): time.sleep(0.2) if _attachment_count(port, S) == 0: - return {"ok": True, "sent": True, "recipient": name, - "image": abs_img, "caption": caption, "error": ""} + image_sent = True + break + if not image_sent: + return fail("la bandeja no se cerro tras pulsar enviar; envio incierto") - return fail("la bandeja no se cerro tras pulsar enviar; envio incierto") + # 7. Caption opcional como mensaje de texto de seguimiento (segunda burbuja). + caption_sent = False + if caption: + m = whatsapp_send_message(name, caption, port=port, target_url_substr=S, + open_first=False) + caption_sent = bool(m.get("sent")) + if not caption_sent: + return {"ok": False, "sent": True, "caption_sent": False, "recipient": name, + "image": abs_img, "caption": caption, + "error": "imagen enviada pero el caption fallo: " + m.get("reason", "")} + + return {"ok": True, "sent": True, "caption_sent": caption_sent, "recipient": name, + "image": abs_img, "caption": caption, "error": ""} if __name__ == "__main__": diff --git a/python/functions/browser/whatsapp_send_image_test.py b/python/functions/browser/whatsapp_send_image_test.py index 4e9f6408..4af99b61 100644 --- a/python/functions/browser/whatsapp_send_image_test.py +++ b/python/functions/browser/whatsapp_send_image_test.py @@ -1,14 +1,14 @@ """Tests para whatsapp_send_image. -whatsapp_send_image compone whatsapp_open_chat con cuatro primitivas CDP (cdp_eval, -cdp_click_xy, cdp_type_chars, cdp_set_file_input) y requiere un Chrome vivo. Aqui se -mockean las cinco con monkeypatch sobre el modulo `browser.whatsapp_send_image` (donde -quedan ligados los nombres por el `from browser.X import Y`), de modo que NO hace falta -Chrome. +whatsapp_send_image compone whatsapp_open_chat con tres primitivas CDP (cdp_eval, +cdp_click_xy, cdp_set_file_input) y `whatsapp_send_message` (para el caption de +seguimiento), y requiere un Chrome vivo. Aqui se mockean todas con monkeypatch sobre el +modulo `browser.whatsapp_send_image` (donde quedan ligados los nombres por el +`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 -ella (`/*ADJUNTAR*/`, `/*PREVIEW*/`, `/*COMPOSER*/`, `/*SEND*/`, `/*LABEL*/`). El estado -`after_send` (que /*SEND*/ activa) hace que /*PREVIEW*/ devuelva 0 adjuntos tras el envio, +ella (`/*ADJUNTAR*/`, `/*PREVIEW*/`, `/*SEND*/`, `/*LABEL*/`). El estado `after_send` (que +/*SEND*/ activa) hace que /*PREVIEW*/ devuelva 0 adjuntos tras el envio de la imagen, simulando el cierre de la bandeja. """ @@ -35,8 +35,7 @@ class _Spy: return self.ret -def _make_eval(caption_value="", label="Escribir un mensaje para el grupo NOTAS WASAP", - state=None): +def _make_eval(label="Escribir un mensaje para el grupo NOTAS WASAP", state=None): state = state if state is not None else {} def fake(expr, *, port=9222, target_url_substr=""): @@ -47,8 +46,6 @@ def _make_eval(caption_value="", label="Escribir un mensaje para el grupo NOTAS return {"ok": True, "value": json.dumps({"x": 1136, "y": 578}), "error": ""} if "/*PREVIEW*/" in expr: return {"ok": True, "value": 0 if state.get("after_send") else 1, "error": ""} - if "/*COMPOSER*/" in expr: - return {"ok": True, "value": caption_value, "error": ""} if "/*LABEL*/" in expr: return {"ok": True, "value": label, "error": ""} return {"ok": True, "value": None, "error": ""} @@ -56,30 +53,32 @@ def _make_eval(caption_value="", label="Escribir un mensaje para el grupo NOTAS return fake -def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None): +def _patch_common(monkeypatch, *, eval_fn, set_ret={"ok": True}, open_ret=None, + send_msg_ret={"sent": True}): open_spy = _Spy(ret=open_ret if open_ret is not None else {"opened": True, "name": "x"}) click_spy = _Spy(ret={"ok": True}) - type_spy = _Spy(ret={"ok": True}) set_spy = _Spy(ret=set_ret) + sendmsg_spy = _Spy(ret=send_msg_ret) monkeypatch.setattr(wsi, "whatsapp_open_chat", open_spy) monkeypatch.setattr(wsi, "cdp_eval", eval_fn) monkeypatch.setattr(wsi, "cdp_click_xy", click_spy) - monkeypatch.setattr(wsi, "cdp_type_chars", type_spy) monkeypatch.setattr(wsi, "cdp_set_file_input", set_spy) + monkeypatch.setattr(wsi, "whatsapp_send_message", sendmsg_spy) monkeypatch.setattr(wsi.time, "sleep", lambda *a, **k: None) - return open_spy, click_spy, type_spy, set_spy + return open_spy, click_spy, set_spy, sendmsg_spy -def test_golden_adjunta_caption_y_envia(monkeypatch): +def test_golden_envia_imagen_y_caption_de_seguimiento(monkeypatch): cap = "item icon: potion" state = {} - open_spy, click_spy, type_spy, set_spy = _patch_common( - monkeypatch, eval_fn=_make_eval(caption_value=cap, state=state)) + open_spy, click_spy, set_spy, sendmsg_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(state=state)) res = whatsapp_send_image("NOTAS WASAP", _IMG, caption=cap, port=9222, target_url_substr="whatsapp") assert res["sent"] is True and res["ok"] is True + assert res["caption_sent"] is True assert res["recipient"] == "NOTAS WASAP" assert res["image"] == _IMG assert res["caption"] == cap @@ -87,30 +86,32 @@ def test_golden_adjunta_caption_y_envia(monkeypatch): # Se adjunto la imagen (ruta absoluta) por setFileInputFiles. assert set_spy.calls[0][0][0] == 'input[type="file"][multiple]' assert set_spy.calls[0][0][1] == _IMG - # Se tecleo el caption una vez. - assert len(type_spy.calls) == 1 - assert type_spy.calls[0][0][0] == cap - # Dos clicks reales: Adjuntar y Enviar. + # Dos clicks reales: Adjuntar y Enviar (bandeja). assert len(click_spy.calls) == 2 + # El caption viaja como mensaje de texto de seguimiento, open_first=False. + assert len(sendmsg_spy.calls) == 1 + assert sendmsg_spy.calls[0][0][0] == "NOTAS WASAP" + assert sendmsg_spy.calls[0][0][1] == cap + assert sendmsg_spy.calls[0][1].get("open_first") is False -def test_envia_sin_caption_no_teclea(monkeypatch): +def test_envia_sin_caption_no_manda_texto(monkeypatch): state = {} - _, click_spy, type_spy, set_spy = _patch_common( - monkeypatch, eval_fn=_make_eval(caption_value="", state=state)) + _, click_spy, set_spy, sendmsg_spy = _patch_common( + monkeypatch, eval_fn=_make_eval(state=state)) res = whatsapp_send_image("NOTAS WASAP", _IMG, caption="", port=9222, target_url_substr="whatsapp") assert res["sent"] is True - # Sin caption: NO se teclea nada, pero si se adjunta y se envia. - assert len(type_spy.calls) == 0 - assert len(set_spy.calls) >= 1 + assert res["caption_sent"] is False + # Sin caption: NO se manda mensaje de texto de seguimiento. + assert len(sendmsg_spy.calls) == 0 assert len(click_spy.calls) == 2 def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch): - open_spy, click_spy, type_spy, set_spy = _patch_common( + open_spy, click_spy, set_spy, sendmsg_spy = _patch_common( monkeypatch, eval_fn=_make_eval()) res = whatsapp_send_image("NOTAS WASAP", "/no/existe/foo.png", @@ -124,7 +125,7 @@ def test_edge_imagen_no_existe_error_sin_abrir(monkeypatch): def test_edge_open_fallido_error_sin_adjuntar(monkeypatch): - open_spy, click_spy, type_spy, set_spy = _patch_common( + open_spy, click_spy, set_spy, sendmsg_spy = _patch_common( monkeypatch, eval_fn=_make_eval(), open_ret={"opened": False, "name": "x", "reason": "chat no encontrado en la lista (no cargado o nombre inexacto)"}) @@ -140,7 +141,7 @@ def test_edge_open_fallido_error_sin_adjuntar(monkeypatch): def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch): - open_spy, click_spy, type_spy, set_spy = _patch_common( + open_spy, click_spy, set_spy, sendmsg_spy = _patch_common( monkeypatch, eval_fn=_make_eval(label="Escribir un mensaje para el grupo OTRO CHAT")) @@ -157,7 +158,7 @@ def test_seguridad_open_first_false_label_no_coincide_aborta(monkeypatch): def test_error_set_file_input_falla_no_envia(monkeypatch): state = {} - open_spy, click_spy, type_spy, set_spy = _patch_common( + open_spy, click_spy, set_spy, sendmsg_spy = _patch_common( monkeypatch, eval_fn=_make_eval(state=state), set_ret={"ok": False, "error": "no element matches selector"}) @@ -169,3 +170,4 @@ def test_error_set_file_input_falla_no_envia(monkeypatch): # Se intento adjuntar (dos selectores) pero no se llego a enviar (solo el click de Adjuntar). assert len(set_spy.calls) == 2 assert len(click_spy.calls) == 1 + assert len(sendmsg_spy.calls) == 0