mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-17 18:25:42 +02:00
156 lines
6.7 KiB
Python
156 lines
6.7 KiB
Python
"""BrowserDeleteItem tool logic (the resolve JS is proven live; this pins the pure pieces and
|
|
the resolve->trusted-click->verify orchestration with a mocked tool runner)."""
|
|
import json
|
|
|
|
import pytest
|
|
|
|
from backend.apps.agents.browser import browser_delete_script as d
|
|
|
|
|
|
def test_flag_default_off(monkeypatch):
|
|
monkeypatch.delenv("OSW_DELETE_SCRIPT", raising=False)
|
|
assert d.delete_tool_enabled() is False
|
|
monkeypatch.setenv("OSW_DELETE_SCRIPT", "1")
|
|
assert d.delete_tool_enabled() is True
|
|
|
|
|
|
def test_expression_embeds_target_json_safe():
|
|
# a target with quotes must not break out of the JS string literal
|
|
tricky = 'he said "hi"; alert(1)'
|
|
expr = d.resolve_expression("more", tricky)
|
|
assert json.dumps(tricky) in expr # embedded as a JSON literal
|
|
assert 'const TARGET = ' + json.dumps(tricky) in expr
|
|
assert "const STEP = \"more\"" in expr
|
|
|
|
|
|
def make_exec(step_results, click_ok=True, verify_after_refresh=None):
|
|
"""Tool-runner mock: BrowserEvaluate returns the scripted resolve result for the step it was
|
|
called with (read out of the expression); BrowserClickPoint records real-input clicks; the
|
|
location probe and BrowserNavigate model the refresh-reverify pass."""
|
|
calls = {"clicks": [], "steps": [], "navs": []}
|
|
|
|
async def execute(tool, params, bid, tid):
|
|
if tool == "BrowserEvaluate":
|
|
expr = params["expression"]
|
|
if "location.href" in expr:
|
|
return {"value": {"href": "https://site.test/profile"}}
|
|
step = expr.split('const STEP = "', 1)[1].split('"', 1)[0]
|
|
calls["steps"].append(step)
|
|
if step == "verify" and calls["navs"] and verify_after_refresh is not None:
|
|
return {"value": verify_after_refresh}
|
|
if step not in step_results: # menu-flow fixtures: no direct control on the item
|
|
return {"value": {"ok": False, "stage": step, "optional": True, "msg": "none"}}
|
|
return {"value": step_results[step]}
|
|
if tool == "BrowserClickPoint":
|
|
calls["clicks"].append((params["xPercent"], params["yPercent"]))
|
|
return {"ok": True} if click_ok else {"error": "no webview"}
|
|
if tool == "BrowserNavigate":
|
|
calls["navs"].append(params["url"])
|
|
return {"ok": True}
|
|
return {"ok": True}
|
|
return execute, calls
|
|
|
|
|
|
POS = {"xPct": 50.0, "yPct": 40.0}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_full_flow_clicks_each_stage_with_real_input():
|
|
ex, calls = make_exec({
|
|
"more": {"ok": True, "stage": "more", **POS},
|
|
"menuitem": {"ok": True, "stage": "menuitem", **POS},
|
|
"confirm": {"ok": True, "stage": "confirm", **POS},
|
|
"verify": {"ok": True, "stage": "verify"},
|
|
})
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", ex)
|
|
assert r == {"removed": True, "stage": "done", "msg": "item removed"}
|
|
assert len(calls["clicks"]) == 3 # more, menuitem, confirm: all real-input clicks
|
|
assert calls["steps"][-1] == "verify"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_verify_still_present_is_honest():
|
|
# still-present survives the refresh re-verify too: honest not-removed, one refresh attempted
|
|
ex, calls = make_exec({
|
|
"more": {"ok": True, "stage": "more", **POS},
|
|
"menuitem": {"ok": True, "stage": "menuitem", **POS},
|
|
"confirm": {"ok": True, "stage": "confirm", **POS},
|
|
"verify": {"ok": False, "stage": "verify"},
|
|
})
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", ex)
|
|
assert r["removed"] is False and r["stage"] == "done"
|
|
assert calls["navs"] == ["https://site.test/profile"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stale_tile_flips_removed_after_refresh():
|
|
"""Shreddit keeps the dead tile mounted until a reload; the refresh re-verify turns a real
|
|
server-side delete into removed=True instead of an honest-but-wrong still-present."""
|
|
ex, calls = make_exec({
|
|
"more": {"ok": True, "stage": "more", **POS},
|
|
"menuitem": {"ok": True, "stage": "menuitem", **POS},
|
|
"confirm": {"ok": True, "stage": "confirm", **POS},
|
|
"verify": {"ok": False, "stage": "verify"},
|
|
}, verify_after_refresh={"ok": True, "stage": "verify"})
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", ex)
|
|
assert r["removed"] is True
|
|
assert len(calls["navs"]) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_confirm_is_optional_verify_decides():
|
|
ex, calls = make_exec({
|
|
"more": {"ok": True, "stage": "more", **POS},
|
|
"menuitem": {"ok": True, "stage": "menuitem", **POS},
|
|
"confirm": {"ok": False, "stage": "confirm", "optional": True, "msg": "no confirm dialog appeared"},
|
|
"verify": {"ok": True, "stage": "verify"},
|
|
})
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", ex)
|
|
assert r["removed"] is True
|
|
assert len(calls["clicks"]) == 2 # confirm never clicked
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_delete_skips_menu_walk():
|
|
"""Row-action sites (Gmail): a literal Delete control on the item is clicked straight; the
|
|
menu ladder never runs and a page-wide non-dialog 'Delete' earns NO bonus confirm click."""
|
|
ex, calls = make_exec({
|
|
"direct": {"ok": True, "stage": "direct", "label": "delete", **POS},
|
|
"confirm": {"ok": True, "stage": "confirm", "fromDialog": False, **POS},
|
|
"verify": {"ok": True, "stage": "verify"},
|
|
})
|
|
r = await d.run_delete("note to self abc123", "b1", "", ex)
|
|
assert r["removed"] is True
|
|
assert len(calls["clicks"]) == 1
|
|
assert "more" not in calls["steps"] and "menuitem" not in calls["steps"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_delete_confirms_only_in_dialog():
|
|
ex, calls = make_exec({
|
|
"direct": {"ok": True, "stage": "direct", "label": "move to trash", **POS},
|
|
"confirm": {"ok": True, "stage": "confirm", "fromDialog": True, **POS},
|
|
"verify": {"ok": True, "stage": "verify"},
|
|
})
|
|
r = await d.run_delete("note to self abc123", "b1", "", ex)
|
|
assert r["removed"] is True
|
|
assert len(calls["clicks"]) == 2 # direct + real-dialog confirm
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stage_failure_reports_stage_and_never_removed():
|
|
ex, calls = make_exec({
|
|
"more": {"ok": False, "stage": "find", "msg": "target text not on this page"},
|
|
})
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", ex)
|
|
assert r == {"removed": False, "stage": "find", "msg": "target text not on this page"}
|
|
assert not calls["clicks"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unreadable_resolve_is_honest_failure():
|
|
async def execute(tool, params, bid, tid):
|
|
return {"error": "eval blew up"}
|
|
r = await d.run_delete("coffee notes abc123", "b1", "", execute)
|
|
assert r["removed"] is False and r["stage"] == "eval"
|