assistant/tests/test_preflight_gate_d44.py

276 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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