276 lines
14 KiB
Python
276 lines
14 KiB
Python
"""Observed navigation enforces only explicitly requested no-save preflight."""
|
||
from copy import deepcopy
|
||
import asyncio
|
||
import json
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock
|
||
|
||
import pytest
|
||
from browser_use import ActionResult
|
||
|
||
from backend.browser import tools, runner
|
||
from backend.browser.preflight_gate import PreflightGate, PreflightScopeError
|
||
from backend.browser.preflight_policy import compile_preflight_policy
|
||
from backend.config import settings
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def enable_reply_relation_preflight(monkeypatch):
|
||
monkeypatch.setattr(settings, 'reply_relation_preflight_enabled', True)
|
||
|
||
|
||
EXCLUDED = '6adaf8a6000000001901d400'
|
||
REQUEST = '本轮只做关系只读预检,不保存任何来源,最多查看2篇新笔记,排除笔记ID为'+EXCLUDED+'。'
|
||
URL = 'https://www.xiaohongshu.com/explore/'
|
||
|
||
|
||
def action(name, **params):
|
||
return SimpleNamespace(model_dump=lambda **kwargs: {name: params})
|
||
|
||
|
||
class Page:
|
||
def __init__(self):
|
||
self.url = 'https://www.xiaohongshu.com/search_result?keyword=fixture'
|
||
self.nodes = {}
|
||
self.live = {}
|
||
self.cdp_calls = []
|
||
self.dom_reads = 0
|
||
async def push(params, session_id):
|
||
self.cdp_calls.append(('push', params, session_id))
|
||
return {'nodeIds': params['backendNodeIds']}
|
||
async def resolve(params, session_id):
|
||
self.cdp_calls.append(('resolve', params, session_id))
|
||
return {'object': {'objectId': str(params['backendNodeId'])}}
|
||
async def call(params, session_id):
|
||
self.cdp_calls.append(('read', params, session_id))
|
||
return {'result': {'value': deepcopy(self.live.get(int(params['objectId'])))}}
|
||
self.cdp_client = SimpleNamespace(send=SimpleNamespace(
|
||
DOM=SimpleNamespace(pushNodesByBackendIdsToFrontend=push, resolveNode=resolve),
|
||
Runtime=SimpleNamespace(callFunctionOn=call)))
|
||
async def get_current_page(self):
|
||
async def get_url(): return self.url
|
||
return SimpleNamespace(get_url=get_url)
|
||
async def get_dom_element_by_index(self, index):
|
||
self.dom_reads += 1
|
||
return self.nodes.get(index)
|
||
|
||
|
||
@pytest.fixture
|
||
def fixture(monkeypatch, tmp_path):
|
||
tid = 'preflight-scope-d44'
|
||
tools.clear_task_context(tid)
|
||
tools.set_prospecting_context(tid)
|
||
monkeypatch.setattr(tools, 'task_doc_dir', lambda _: tmp_path)
|
||
page = Page()
|
||
gate = PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'f'*32, 1)
|
||
yield tid, page, gate, tmp_path
|
||
tools.clear_task_context(tid)
|
||
|
||
|
||
async def test_excluded_navigate_is_rejected_before_tools_act_without_opening_or_saving(fixture):
|
||
tid, page, gate, _ = fixture
|
||
async def check(item):
|
||
message = await gate.before_action(item)
|
||
return ActionResult(error=message) if message else None
|
||
delegate = SimpleNamespace(act=AsyncMock(side_effect=AssertionError('excluded target may not open')))
|
||
boundary = runner._ManagedActionBoundary(delegate, check)
|
||
result = await boundary.act(action=action('navigate', url=URL+EXCLUDED+'?xsec_token=private'))
|
||
assert result.error and page.url.endswith('keyword=fixture')
|
||
delegate.act.assert_not_awaited()
|
||
artifacts = tools.get_task_artifacts(tid)
|
||
assert artifacts['preflight_navigation'][-1]['status'] == 'excluded_note_blocked'
|
||
assert all(not artifacts.get(key) for key in ('sources', 'operation_evidence', 'learning_candidates', 'learning_outcomes'))
|
||
|
||
|
||
async def test_index_child_uses_real_ancestor_anchor_and_never_guesses_from_data_id(fixture):
|
||
tid, page, gate, _ = fixture
|
||
anchor = SimpleNamespace(node_name='A', attributes={'href': '/explore/'+EXCLUDED}, parent_node=None)
|
||
page.nodes[3] = SimpleNamespace(node_name='IMG', attributes={}, parent_node=anchor, backend_node_id=3, session_id='dom-session')
|
||
page.live[3] = {'href': '/explore/'+EXCLUDED, 'absolute_href': URL+EXCLUDED}
|
||
assert await gate.before_action(action('click', index=3))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'excluded_note_blocked'
|
||
page.nodes[4] = SimpleNamespace(node_name='DIV', attributes={'data-note-id': EXCLUDED}, parent_node=None)
|
||
assert await gate.before_action(action('click', index=4))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'unknown_click_blocked'
|
||
assert await gate.before_action(action('click', coordinate_x=100, coordinate_y=200))
|
||
|
||
|
||
async def test_actual_distinct_visits_not_click_attempts_set_the_limit_and_survive_next_run(fixture):
|
||
tid, page, gate, _ = fixture
|
||
for note_id in ('a'*24, 'b'*24):
|
||
assert await gate.before_action(action('navigate', url=URL+note_id)) is None
|
||
page.url = URL+note_id
|
||
await gate.observe()
|
||
assert gate.visited == {'a'*24, 'b'*24}
|
||
assert await gate.before_action(action('navigate', url=URL+'c'*24))
|
||
page.url = 'https://www.xiaohongshu.com/search_result?keyword=fixture'
|
||
next_run = PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'e'*32, 1)
|
||
assert await next_run.before_action(action('navigate', url=URL+'c'*24))
|
||
assert await next_run.before_action(action('navigate', url=URL+'a'*24)) is None
|
||
assert len([row for row in tools.get_task_artifacts(tid)['preflight_navigation'] if row['status'] == 'entered_note']) == 2
|
||
|
||
|
||
async def test_unexpected_redirect_is_stopped_before_reading_excluded_dom(fixture):
|
||
tid, page, gate, _ = fixture
|
||
assert await gate.before_action(action('navigate', url=URL+'a'*24)) is None
|
||
page.url = URL+EXCLUDED
|
||
with pytest.raises(PreflightScopeError, match='excluded_or_extra_note'):
|
||
await gate.observe()
|
||
assert page.dom_reads == 0
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'unexpected_note_rejected'
|
||
|
||
|
||
async def test_untracked_old_run_is_not_reset_to_zero_or_backfilled(fixture):
|
||
tid, page, _gate, _ = fixture
|
||
artifacts = tools._task_artifacts[tid]
|
||
artifacts['preflight_navigation'] = []
|
||
artifacts.pop('preflight_run', None)
|
||
history = {'steps_this_run': 7, 'urls': [URL+'a'*24, URL+EXCLUDED, URL+'b'*24]}
|
||
artifacts['browser_history'] = deepcopy(history)
|
||
with pytest.raises(PreflightScopeError, match='prior_scope_untracked'):
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'd'*32, 1)
|
||
assert artifacts['browser_history'] == history
|
||
assert [row['status'] for row in artifacts['preflight_navigation']] == ['scope_history_untracked']
|
||
assert 'preflight_run' not in artifacts
|
||
with pytest.raises(PreflightScopeError, match='prior_scope_untracked'):
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'c'*32, 2)
|
||
assert 'preflight_run' not in artifacts
|
||
|
||
|
||
async def test_tracked_login_pause_with_no_note_does_not_create_false_unknown_history(fixture):
|
||
tid, page, _gate, _ = fixture
|
||
tools._task_artifacts[tid]['browser_history'] = {'steps_this_run': 2, 'urls': ['https://www.xiaohongshu.com/login']}
|
||
continued = PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'c'*32, 1)
|
||
assert await continued.before_action(action('navigate', url=URL+'a'*24)) is None
|
||
|
||
|
||
@pytest.mark.parametrize('name', ['save_source', 'save_learning', 'expand_replies', 'resolve_reply_target',
|
||
'inspect_profile', 'open_source_profile', 'report_learning_outcome', 'evaluate', 'message', 'follow'])
|
||
async def test_no_save_preflight_does_not_allow_other_evidence_or_mutation_tools(fixture, name):
|
||
tid, _page, gate, _ = fixture
|
||
assert await gate.before_action(action(name, content='fixture'))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'action_not_allowed'
|
||
|
||
|
||
async def test_scope_checkpoint_stores_only_program_metadata(fixture):
|
||
_tid, page, gate, directory = fixture
|
||
page.url = URL+'a'*24+'?xsec_token=private'
|
||
await gate.observe()
|
||
text = (directory/'evidence.json').read_text()
|
||
assert 'private' not in text and 'https://' not in text
|
||
assert 'preflight_navigation' in text and 'preflight_run' in text
|
||
|
||
|
||
async def test_input_and_enter_cannot_post_to_comments_during_preflight(fixture):
|
||
_tid, page, gate, _ = fixture
|
||
assert await gate.before_action(action('input', index=1, text='杭州装修')) is None
|
||
assert await gate.before_action(action('send_keys', keys='ENTER')) is None
|
||
page.url = URL+'a'*24
|
||
assert await gate.before_action(action('input', index=1, text='评论'))
|
||
assert await gate.before_action(action('send_keys', keys='ENTER'))
|
||
|
||
|
||
async def test_profile_names_and_fragment_controls_cannot_bypass_read_only_scope(fixture):
|
||
tid, page, gate, _ = fixture
|
||
page.nodes[7] = SimpleNamespace(node_name='A', attributes={'href': '/user/profile/'+'d'*24}, parent_node=None,
|
||
backend_node_id=7, session_id='dom-session')
|
||
page.live[7] = {'href': '/user/profile/'+'d'*24, 'absolute_href': 'https://www.xiaohongshu.com/user/profile/'+'d'*24}
|
||
assert await gate.before_action(action('click', index=7))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'action_not_allowed'
|
||
page.nodes[8] = SimpleNamespace(node_name='A', attributes={'href': '#'}, parent_node=None)
|
||
assert await gate.before_action(action('click', index=8))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'unknown_click_blocked'
|
||
|
||
|
||
@pytest.mark.parametrize('live_id,expected_status', [(EXCLUDED, 'excluded_note_blocked'), ('b'*24, 'unknown_click_blocked')])
|
||
async def test_live_href_change_after_model_snapshot_is_rejected_before_native_click(fixture, live_id, expected_status):
|
||
tid, page, gate, _ = fixture
|
||
anchor = SimpleNamespace(node_name='A', attributes={'href': '/explore/'+'a'*24}, parent_node=None)
|
||
page.nodes[5] = SimpleNamespace(node_name='IMG', attributes={}, parent_node=anchor,
|
||
backend_node_id=51, session_id='dom-session')
|
||
page.live[51] = {'href': '/explore/'+live_id, 'absolute_href': URL+live_id}
|
||
async def check(item):
|
||
error = await gate.before_action(item)
|
||
return ActionResult(error=error) if error else None
|
||
delegate = SimpleNamespace(act=AsyncMock(side_effect=AssertionError('changed link must not be clicked')))
|
||
result = await runner._ManagedActionBoundary(delegate, check).act(action=action('click', index=5))
|
||
assert result.error and page.url.endswith('keyword=fixture')
|
||
delegate.act.assert_not_awaited()
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == expected_status
|
||
assert page.cdp_calls[0] == ('resolve', {'backendNodeId': 51}, 'dom-session')
|
||
assert [call[0] for call in page.cdp_calls] == ['resolve', 'read']
|
||
assert 'closest' in page.cdp_calls[-1][1]['functionDeclaration']
|
||
|
||
|
||
async def test_missing_live_node_or_stable_identity_cannot_authorize_cached_href(fixture):
|
||
tid, page, gate, _ = fixture
|
||
page.nodes[9] = SimpleNamespace(node_name='A', attributes={'href': '/explore/'+'a'*24}, parent_node=None)
|
||
assert await gate.before_action(action('click', index=9))
|
||
assert not page.cdp_calls
|
||
page.nodes[9].backend_node_id, page.nodes[9].session_id = 9, 'dom-session'
|
||
assert await gate.before_action(action('click', index=9)) # Detached/no live anchor.
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'unknown_click_blocked'
|
||
|
||
|
||
async def test_matching_current_live_anchor_allows_the_observed_target(fixture):
|
||
_tid, page, gate, _ = fixture
|
||
page.nodes[10] = SimpleNamespace(node_name='A', attributes={'href': '/explore/'+'a'*24}, parent_node=None,
|
||
backend_node_id=10, session_id='dom-session')
|
||
page.live[10] = {'href': '/explore/'+'a'*24, 'absolute_href': URL+'a'*24}
|
||
assert await gate.before_action(action('click', index=10)) is None
|
||
|
||
|
||
@pytest.mark.parametrize('path', ['/redirect?target='+URL+EXCLUDED, '/unknown-route', '/explore/not-a-stable-id',
|
||
'/discovery/item/not-a-stable-id', '/user/other', '/explore/extra/path'])
|
||
async def test_unknown_xhs_routes_cannot_bypass_preflight_exclusion_and_count(fixture, path):
|
||
tid, page, gate, _ = fixture
|
||
assert await gate.before_action(action('navigate', url='https://www.xiaohongshu.com'+path))
|
||
assert tools.get_task_artifacts(tid)['preflight_navigation'][-1]['status'] == 'unknown_click_blocked'
|
||
assert page.url.endswith('keyword=fixture')
|
||
|
||
|
||
@pytest.mark.parametrize('path', ['/', '/explore', '/search_result?keyword=fixture', '/search_result_ai?keyword=fixture'])
|
||
async def test_explicit_home_and_search_paths_remain_available(fixture, path):
|
||
_tid, _page, gate, _ = fixture
|
||
assert await gate.before_action(action('navigate', url='https://www.xiaohongshu.com'+path)) is None
|
||
|
||
|
||
async def test_untracked_scope_is_persistently_rejected_before_first_observe_or_cancellation_window(fixture):
|
||
tid, page, _gate, directory = fixture
|
||
saved = tools._task_artifacts[tid]
|
||
saved.pop('preflight_run', None)
|
||
saved['preflight_navigation'] = []
|
||
old_history = {'steps_this_run': 3, 'urls': [URL+EXCLUDED]}
|
||
saved['browser_history'] = deepcopy(old_history)
|
||
with pytest.raises(PreflightScopeError):
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'c'*32, 1)
|
||
snapshot = json.loads((directory/'evidence.json').read_text())
|
||
assert snapshot['preflight_navigation'][-1]['status'] == 'scope_history_untracked'
|
||
assert 'preflight_run' not in snapshot
|
||
# A later invocation, even after the first never reached observe(), cannot
|
||
# turn that failed setup into a tracked zero-note run.
|
||
with pytest.raises(PreflightScopeError):
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'd'*32, 1)
|
||
assert saved['browser_history'] == old_history and 'preflight_run' not in saved
|
||
|
||
|
||
async def test_cancellation_cleanup_after_failed_construction_cannot_hide_untracked_history(fixture):
|
||
tid, page, _gate, directory = fixture
|
||
saved = tools._task_artifacts[tid]
|
||
saved.pop('preflight_run', None)
|
||
saved['preflight_navigation'] = []
|
||
saved['browser_history'] = {'steps_this_run': 3}
|
||
async def cancelled_setup():
|
||
try:
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'b'*32, 1)
|
||
finally:
|
||
raise asyncio.CancelledError
|
||
job = asyncio.create_task(cancelled_setup())
|
||
with pytest.raises(asyncio.CancelledError):
|
||
await job
|
||
snapshot = json.loads((directory/'evidence.json').read_text())
|
||
assert snapshot['preflight_navigation'][-1]['status'] == 'scope_history_untracked'
|
||
assert 'preflight_run' not in snapshot
|
||
with pytest.raises(PreflightScopeError, match='prior_scope_untracked'):
|
||
PreflightGate(tid, page, compile_preflight_policy(REQUEST), 'a'*32, 1)
|