"""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)