mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
141 lines
5.4 KiB
Python
141 lines
5.4 KiB
Python
"""A cleared composer is not proof when the site just said no.
|
|
|
|
The whole fast write path rests on one signal: the payload left the composer. browser_send_script
|
|
has always carried the admission that this "cannot tell submitted from dismissed", guarded only for
|
|
guessed clicks and ghost-drop hosts. The realistic way it bites is neither: the site accepts the
|
|
click, clears the box, and pops "Something went wrong" or a rate limit. The receipt then reads as a
|
|
clean success and the agent tells the user it posted.
|
|
|
|
The guard reads only the page's live announcement regions (role=alert, aria-live), which is the
|
|
accessibility contract sites already follow, so it needs no per-site knowledge. It can only ever
|
|
DEMOTE a claim, never manufacture one, and it fails open: a broken probe returns "not rejected"
|
|
rather than inventing a failure that did not happen.
|
|
"""
|
|
import pytest
|
|
|
|
from backend.apps.agents.browser import browser_delivery_check as dc
|
|
|
|
REJECTIONS = [
|
|
"Something went wrong. Try again.",
|
|
"Couldn't post your reply",
|
|
"Could not send message",
|
|
"Unable to post right now",
|
|
"Failed to send",
|
|
"You've reached your daily limit. Try again later.",
|
|
"Too many requests, please slow down",
|
|
"Rate limit exceeded",
|
|
"Your post wasn't sent",
|
|
"An error occurred",
|
|
]
|
|
|
|
# A live region is also how sites announce SUCCESS and ordinary chatter. Matching these would turn
|
|
# every good send into a scary "it was rejected", which is a worse lie than the one being fixed.
|
|
NOT_REJECTIONS = [
|
|
"Your post was sent.",
|
|
"Posted",
|
|
"Message sent",
|
|
"Draft saved",
|
|
"1 new notification",
|
|
"Copied to clipboard",
|
|
"",
|
|
" ",
|
|
]
|
|
|
|
|
|
def p_probe(text):
|
|
"""A fake BrowserEvaluate returning whatever the announcement regions supposedly held.
|
|
|
|
Shape matters: parse_eval_value reads {"value": ...} at the TOP level. An earlier version of
|
|
this fixture nested it one level deeper, which made every negative case pass for the wrong
|
|
reason (unreadable result, not correct matching) while every positive case failed loudly."""
|
|
async def run(tool, args, browser_id, tab_id):
|
|
assert tool == "BrowserEvaluate"
|
|
return {"value": {"text": text}}
|
|
return run
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("text", REJECTIONS)
|
|
async def test_a_refusal_is_detected(text):
|
|
assert await dc.send_rejected("b", "t", p_probe(text)) is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("text", NOT_REJECTIONS)
|
|
async def test_success_and_chatter_are_left_alone(text):
|
|
assert await dc.send_rejected("b", "t", p_probe(text)) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_broken_probe_does_not_invent_a_failure():
|
|
"""Fail OPEN. Claiming rejection because we could not read the page would be the same class of
|
|
lie in the other direction."""
|
|
async def boom(tool, args, browser_id, tab_id):
|
|
raise RuntimeError("evaluate died")
|
|
assert await dc.send_rejected("b", "t", boom) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_hung_probe_does_not_block_the_send_path():
|
|
"""The probe sits on the irreversible path; it must time out rather than wedge the turn."""
|
|
import asyncio
|
|
|
|
async def hang(tool, args, browser_id, tab_id):
|
|
await asyncio.sleep(30)
|
|
return {}
|
|
assert await dc.send_rejected("b", "t", hang) is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_a_junk_shaped_result_is_not_a_rejection():
|
|
async def junk(tool, args, browser_id, tab_id):
|
|
return {"value": "not a dict"}
|
|
assert await dc.send_rejected("b", "t", junk) is False
|
|
|
|
|
|
# --- the probe itself ------------------------------------------------------------------------
|
|
|
|
def test_the_probe_reads_only_announcement_regions():
|
|
"""Scope is the entire point. Whole-page text contains the word 'failed' on a huge fraction of
|
|
the internet, and matching that would demote correct sends at random."""
|
|
js = dc.rejection_probe_expression()
|
|
assert "role=alert" in js
|
|
assert "aria-live" in js
|
|
assert "document.body" not in js, "the probe must not fall back to whole-page text"
|
|
|
|
|
|
def test_the_probe_bounds_what_it_returns():
|
|
"""An unbounded innerText from a chatty live region would bloat every send's payload."""
|
|
assert "slice(0,600)" in dc.rejection_probe_expression()
|
|
|
|
|
|
# --- what the user is told -------------------------------------------------------------------
|
|
|
|
def test_the_rejection_note_states_plainly_that_it_did_not_send():
|
|
note = dc.rejected_send_note("https://x.com/home", "hello there")
|
|
assert "did NOT go through" in note
|
|
assert "x.com" in note and "www." not in note
|
|
assert "hello there" in note
|
|
|
|
|
|
def test_the_rejection_note_says_it_will_not_retry():
|
|
"""A blind retry on a refusal is how you get rate-limited harder, or post twice if the refusal
|
|
was cosmetic."""
|
|
assert "did not retry" in dc.rejected_send_note("https://x.com", "hi").lower()
|
|
|
|
|
|
def test_a_long_payload_is_clipped_in_the_note():
|
|
note = dc.rejected_send_note("https://x.com", "y" * 500)
|
|
assert "..." in note
|
|
assert len(note) < 400
|
|
|
|
|
|
def test_the_rejection_note_is_distinct_from_the_unverified_one():
|
|
"""Different evidence, different claim. Collapsing them would either overclaim or send the user
|
|
to go check something we already know."""
|
|
rejected = dc.rejected_send_note("https://x.com", "hi")
|
|
unverified = dc.unverified_send_note("https://x.com", "hi")
|
|
assert rejected != unverified
|
|
assert "could NOT confirm" in unverified
|
|
assert "could NOT confirm" not in rejected
|