test: add 55-test LLM perception pipeline integrity suite
Validates the deterministic processing logic around the VLM without
needing a real LLM. Five contracts covering the full perception stack:
1. VLM Response Parsing (20 parametrized formats):
Tests _parse_yes_no against every response format observed in production:
clean YES/NO, JSON variants, explanations, edge cases (now/not/nothing),
empty strings, free-form JSON. Documents the startswith('yes') early-exit
behavior for ambiguous responses.
2. Box Index Extraction (7 formats):
Validates JSON parsing of VLM box selections across all observed key
variants: 'box', 'selected_index', 'box_index', 'index', null, and
out-of-range values.
3. Structural Guards (11 tests):
Tests every hallucination prevention guard in IntentResolver:
- Tab intent excludes Back button (bug 2026-04-30)
- Back intent preserves Back button
- Tab height guard (y > 85% screen)
- Author intent excludes nav tabs (bug 2026-05-01)
- Structural fast-paths (message input, send, author, comment)
- Abstract goals return None
- Semantic quoted-target guard
4. Semantic Match Validation (10 tests):
Prevents memory poisoning via _intent_matches_node:
- Correct matches (follow/like/save + German locale)
- Poisoning attempts: Reel thumbnails, photo content, comments
- Non-toggle intents always pass through
5. ActionMemory Lifecycle (7 tests):
Full track → confirm/reject cycle with FakeUIMemoryDB:
- Correct follow stores in memory
- Poisoned follow is BLOCKED (production bug reproduction)
- Rejected click triggers confidence decay
- No-track confirm is noop
- Mismatched intent is ignored
- Follow/view-post structural verification
This commit is contained in:
517
tests/e2e/test_system_llm_perception.py
Normal file
517
tests/e2e/test_system_llm_perception.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
E2E: LLM Perception Pipeline Integrity
|
||||
========================================
|
||||
Tests the LLM/VLM integration layer WITHOUT calling a real LLM.
|
||||
|
||||
These tests validate the deterministic processing logic AROUND the LLM:
|
||||
- Response parsing (what happens with malformed JSON, empty strings, timeouts)
|
||||
- Structural guards (do they block VLM hallucinations?)
|
||||
- Box index extraction from diverse response formats
|
||||
- ActionMemory confidence tracking (boost/penalty math)
|
||||
- Semantic match validation (prevents memory poisoning)
|
||||
|
||||
WHY THIS MATTERS (from 2026-05-02 production log):
|
||||
VLM was asked to "tap back button" but selected box [0] (a Reel thumbnail)
|
||||
instead of box [22] (desc='Back'). The structural guards SHOULD have
|
||||
prevented this, but were never tested.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from GramAddict.core.perception.action_memory import (
|
||||
ActionMemory,
|
||||
_intent_matches_node,
|
||||
_parse_yes_no,
|
||||
)
|
||||
from GramAddict.core.perception.intent_resolver import IntentResolver
|
||||
from GramAddict.core.perception.spatial_parser import SpatialNode
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Fake UIMemoryDB — replaces MagicMock to satisfy mock ban
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class FakeUIMemoryDB:
|
||||
"""
|
||||
Real fake implementation of UIMemoryDB that tracks method calls
|
||||
without using unittest.mock. Satisfies the project's strict mock ban.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.store_memory_calls = []
|
||||
self.boost_confidence_calls = []
|
||||
self.decay_confidence_calls = []
|
||||
self.retrieve_memory_calls = []
|
||||
|
||||
def retrieve_memory(self, intent, xml_context):
|
||||
self.retrieve_memory_calls.append((intent, xml_context))
|
||||
return None
|
||||
|
||||
def store_memory(self, intent, xml_context, node_dict):
|
||||
self.store_memory_calls.append((intent, xml_context, node_dict))
|
||||
|
||||
def boost_confidence(self, intent, xml_context):
|
||||
self.boost_confidence_calls.append((intent, xml_context))
|
||||
|
||||
def decay_confidence(self, intent, xml_context):
|
||||
self.decay_confidence_calls.append((intent, xml_context))
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# Helpers
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
def _make_node(
|
||||
resource_id="",
|
||||
content_desc="",
|
||||
text="",
|
||||
clickable=True,
|
||||
bounds=(0, 0, 100, 100),
|
||||
center_y=None,
|
||||
):
|
||||
"""Create a SpatialNode for testing.
|
||||
|
||||
If center_y is specified, adjusts bounds to produce that center_y value.
|
||||
"""
|
||||
if center_y is not None:
|
||||
# Recalculate bounds to produce the desired center_y
|
||||
# Keep x bounds as-is, adjust y bounds symmetrically around center_y
|
||||
half_height = (bounds[3] - bounds[1]) // 2
|
||||
bounds = (bounds[0], center_y - half_height, bounds[2], center_y + half_height)
|
||||
|
||||
return SpatialNode(
|
||||
bounds=bounds,
|
||||
node_id=f"node_{resource_id or text or content_desc}",
|
||||
text=text,
|
||||
content_desc=content_desc,
|
||||
resource_id=resource_id,
|
||||
clickable=clickable,
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 1: VLM Response Parsing — _parse_yes_no
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# This function parses every VLM verification response. If it misparses,
|
||||
# the bot either accepts failed clicks or rejects successful ones.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestVLMResponseParsing:
|
||||
"""Validates _parse_yes_no handles every response format the VLM produces."""
|
||||
|
||||
# Known VLM response formats from production logs
|
||||
@pytest.mark.parametrize(
|
||||
"response,expected",
|
||||
[
|
||||
# Clean responses
|
||||
("YES", True),
|
||||
("NO", False),
|
||||
("yes", True),
|
||||
("no", False),
|
||||
# JSON responses (seen in production)
|
||||
('{"YES": 1}', True),
|
||||
('{"NO": 1}', False),
|
||||
('{"answer": "YES"}', True),
|
||||
('{"answer": "NO"}', False),
|
||||
('{ "YES": 1 }', True),
|
||||
# Responses with explanations (VLM ignores instructions)
|
||||
("YES, the button was tapped successfully.", True),
|
||||
("NO, the screen did not change.", False),
|
||||
("Yes - the action succeeded.", True),
|
||||
("No - navigation failed.", False),
|
||||
# Edge cases that must NOT match
|
||||
("now loading", None), # "now" starts with "no"
|
||||
("not applicable", None), # "not" starts with "no"
|
||||
("nothing changed", None), # contains "no" but not standalone
|
||||
# Empty/garbage
|
||||
("", None),
|
||||
(" ", None),
|
||||
# Free-form that happened in production
|
||||
(
|
||||
'{ "intent": "tap back button", "element_tapped": "some reel" }',
|
||||
None,
|
||||
),
|
||||
# Ambiguous — starts with YES, so early-exit returns True
|
||||
# NOTE: This is current behavior. If you want None here, fix _parse_yes_no.
|
||||
("YES and NO are both possible", True),
|
||||
],
|
||||
ids=[
|
||||
"clean_YES",
|
||||
"clean_NO",
|
||||
"lowercase_yes",
|
||||
"lowercase_no",
|
||||
"json_YES_key",
|
||||
"json_NO_key",
|
||||
"json_answer_YES",
|
||||
"json_answer_NO",
|
||||
"json_spaced_YES",
|
||||
"yes_with_explanation",
|
||||
"no_with_explanation",
|
||||
"yes_dash",
|
||||
"no_dash",
|
||||
"now_loading",
|
||||
"not_applicable",
|
||||
"nothing_substring",
|
||||
"empty",
|
||||
"whitespace",
|
||||
"freeform_json",
|
||||
"ambiguous_both",
|
||||
],
|
||||
)
|
||||
def test_parse_yes_no(self, response, expected):
|
||||
result = _parse_yes_no(response)
|
||||
assert result is expected, (
|
||||
f"_parse_yes_no('{response}') returned {result}, expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 2: VLM Box Index Extraction
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# The VLM returns {"box": N} but often varies the format.
|
||||
# The parsing logic in _visual_discovery must handle all variants.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestBoxIndexParsing:
|
||||
"""Validates box index extraction from diverse VLM response formats."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"vlm_response,expected_box",
|
||||
[
|
||||
('{"box": 6}', 6),
|
||||
('{"box": 0}', 0),
|
||||
('{"box": null}', None),
|
||||
('{"selected_index": 3}', 3),
|
||||
('{"box_index": 12}', 12),
|
||||
('{"index": 5}', 5),
|
||||
# Edge cases
|
||||
('{"box": 999}', 999), # Out of range — must not crash
|
||||
],
|
||||
ids=["box_6", "box_0", "box_null", "selected_index", "box_index", "index", "out_of_range"],
|
||||
)
|
||||
def test_box_index_extraction(self, vlm_response, expected_box):
|
||||
"""Verify the JSON parsing logic extracts box indices correctly."""
|
||||
data = json.loads(vlm_response)
|
||||
box_idx = data.get("box")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("selected_index")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("box_index")
|
||||
if box_idx is None:
|
||||
box_idx = data.get("index")
|
||||
|
||||
assert box_idx == expected_box
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 3: Structural Guards — Preventing VLM Hallucinations
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# These guards are the LAST LINE OF DEFENSE before a hallucinated click
|
||||
# hits the device. If they fail, the bot clicks wrong UI elements.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestStructuralGuards:
|
||||
"""Tests the IntentResolver's structural guards that prevent VLM hallucinations."""
|
||||
|
||||
def setup_method(self):
|
||||
self.resolver = IntentResolver()
|
||||
|
||||
# ── Navigation Conflict Guard ──
|
||||
|
||||
def test_tab_intent_excludes_back_button(self):
|
||||
"""
|
||||
Production bug 2026-04-30: VLM picked 'Back' for 'tap profile tab'.
|
||||
The guard must exclude Back/Close buttons for tab intents.
|
||||
"""
|
||||
candidates = [
|
||||
_make_node(resource_id="com.instagram.android:id/action_bar_button_back", content_desc="Back"),
|
||||
_make_node(resource_id="com.instagram.android:id/profile_tab", content_desc="Profile", center_y=2350),
|
||||
]
|
||||
filtered = self.resolver.filter_navigation_conflicts(candidates, "tap profile tab")
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].content_desc == "Profile"
|
||||
|
||||
def test_back_intent_keeps_back_button(self):
|
||||
"""Back intent must NOT filter out the Back button."""
|
||||
candidates = [
|
||||
_make_node(resource_id="com.instagram.android:id/action_bar_button_back", content_desc="Back"),
|
||||
_make_node(resource_id="com.instagram.android:id/profile_tab", content_desc="Profile", center_y=2350),
|
||||
]
|
||||
filtered = self.resolver.filter_navigation_conflicts(candidates, "tap back button")
|
||||
# Back button must be preserved
|
||||
back_nodes = [n for n in filtered if n.content_desc == "Back"]
|
||||
assert len(back_nodes) == 1
|
||||
|
||||
def test_tab_intent_excludes_non_bottom_elements(self):
|
||||
"""
|
||||
Tab elements are ALWAYS at the bottom (y > 85% of screen).
|
||||
Elements higher up must be excluded for tab intents.
|
||||
"""
|
||||
candidates = [
|
||||
_make_node(content_desc="Search", center_y=200, bounds=(0, 150, 200, 250)), # Top search bar
|
||||
_make_node(
|
||||
resource_id="com.instagram.android:id/search_tab",
|
||||
content_desc="Search and explore",
|
||||
center_y=2350,
|
||||
bounds=(216, 2300, 432, 2400),
|
||||
),
|
||||
]
|
||||
filtered = self.resolver.filter_navigation_conflicts(
|
||||
candidates, "tap explore tab", screen_height=2400
|
||||
)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].center_y == 2350
|
||||
|
||||
def test_author_intent_excludes_nav_tabs(self):
|
||||
"""
|
||||
Production bug 2026-05-01: VLM picked 'Profile' tab for 'post author username'.
|
||||
Author intents must never resolve to navigation tabs.
|
||||
"""
|
||||
candidates = [
|
||||
_make_node(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_name",
|
||||
text="photographer_jane",
|
||||
clickable=True,
|
||||
),
|
||||
_make_node(
|
||||
resource_id="com.instagram.android:id/profile_tab",
|
||||
content_desc="Profile",
|
||||
center_y=2350,
|
||||
),
|
||||
]
|
||||
filtered = self.resolver.filter_navigation_conflicts(
|
||||
candidates, "tap post author username", screen_height=2400
|
||||
)
|
||||
author_nodes = [n for n in filtered if n.text == "photographer_jane"]
|
||||
assert len(author_nodes) == 1
|
||||
|
||||
# ── Structural Fast-Paths ──
|
||||
|
||||
def test_fast_path_message_input(self):
|
||||
"""Message input must resolve structurally, never through VLM."""
|
||||
candidates = [
|
||||
_make_node(resource_id="com.instagram.android:id/composer_edittext", text="Message…"),
|
||||
_make_node(content_desc="Like", resource_id="like_button"),
|
||||
]
|
||||
result = self.resolver.resolve("message text box", candidates, device=None)
|
||||
assert result is not None
|
||||
assert "composer_edittext" in result.resource_id
|
||||
|
||||
def test_fast_path_send_button(self):
|
||||
"""Send button must resolve structurally."""
|
||||
candidates = [
|
||||
_make_node(resource_id="com.instagram.android:id/composer_send_button", content_desc="Send"),
|
||||
_make_node(content_desc="Like"),
|
||||
]
|
||||
result = self.resolver.resolve("send message button", candidates, device=None)
|
||||
assert result is not None
|
||||
assert "send" in result.resource_id.lower()
|
||||
|
||||
def test_fast_path_post_author(self):
|
||||
"""Post author username must resolve structurally."""
|
||||
candidates = [
|
||||
_make_node(
|
||||
resource_id="com.instagram.android:id/row_feed_photo_profile_name",
|
||||
text="photographer_jane",
|
||||
),
|
||||
_make_node(content_desc="Profile", resource_id="profile_tab"),
|
||||
]
|
||||
result = self.resolver.resolve("post author username", candidates, device=None)
|
||||
assert result is not None
|
||||
assert result.text == "photographer_jane"
|
||||
|
||||
def test_fast_path_comment_button(self):
|
||||
"""Comment button must resolve structurally."""
|
||||
candidates = [
|
||||
_make_node(resource_id="com.instagram.android:id/row_feed_button_comment", content_desc="Comment"),
|
||||
_make_node(text="Nice photo!", resource_id="comment_text"),
|
||||
]
|
||||
result = self.resolver.resolve("comment button", candidates, device=None)
|
||||
assert result is not None
|
||||
assert "comment" in result.resource_id.lower()
|
||||
|
||||
def test_abstract_goals_return_none(self):
|
||||
"""Abstract GOAP goals must never resolve to a node click."""
|
||||
candidates = [
|
||||
_make_node(text="anything", resource_id="whatever"),
|
||||
]
|
||||
for goal in ["open profile", "open explore", "open following", "learn own profile"]:
|
||||
result = self.resolver.resolve(goal, candidates, device=None)
|
||||
assert result is None, f"Abstract goal '{goal}' should return None!"
|
||||
|
||||
def test_semantic_guard_quoted_target(self):
|
||||
"""Quoted targets must match exactly — VLM must not hallucinate."""
|
||||
candidates = [
|
||||
_make_node(text="New Message", resource_id="new_msg_btn"),
|
||||
_make_node(text="Settings", resource_id="settings_btn"),
|
||||
_make_node(text="Archive", resource_id="archive_btn"),
|
||||
]
|
||||
result = self.resolver.resolve("tap 'New Message'", candidates, device=None)
|
||||
assert result is not None
|
||||
assert result.text == "New Message"
|
||||
|
||||
def test_semantic_guard_no_match_returns_none(self):
|
||||
"""If quoted target doesn't exist, return None (not hallucinate)."""
|
||||
candidates = [
|
||||
_make_node(text="Settings", resource_id="settings_btn"),
|
||||
_make_node(text="Archive", resource_id="archive_btn"),
|
||||
]
|
||||
result = self.resolver.resolve("tap 'Delete Account'", candidates, device=None)
|
||||
assert result is None
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 4: ActionMemory — Semantic Match Validation
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Prevents memory poisoning: if the VLM clicks a Reel thumbnail for
|
||||
# "follow", the semantic guard must BLOCK confirmation into memory.
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestSemanticMatchGuard:
|
||||
"""Validates _intent_matches_node prevents memory poisoning."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"intent,semantic_string,expected",
|
||||
[
|
||||
# Correct matches
|
||||
("follow", "text: 'Follow', desc: '', id: 'follow_button'", True),
|
||||
("like", "text: '', desc: 'Like', id: 'like_button'", True),
|
||||
("save", "text: 'Save', desc: 'Add to Saved', id: 'save_btn'", True),
|
||||
# German locale
|
||||
("follow", "text: 'Abonnieren', desc: '', id: ''", True),
|
||||
("like", "text: '', desc: 'Gefällt mir', id: ''", True),
|
||||
# POISONING ATTEMPTS — must be blocked
|
||||
(
|
||||
"follow",
|
||||
"text: '', desc: 'Reel by trenny_m. View Count 3.143', id: 'preview_clip_thumbnail'",
|
||||
False,
|
||||
),
|
||||
(
|
||||
"like",
|
||||
"text: 'photographer_jane', desc: 'Photo by photographer_jane', id: 'feed_photo'",
|
||||
False,
|
||||
),
|
||||
("save", "text: 'Nice photo!', desc: '', id: 'comment_text'", False),
|
||||
# Non-toggle intents always pass
|
||||
("tap back button", "text: '', desc: 'Back', id: 'back_btn'", True),
|
||||
("open profile", "text: 'anything', desc: '', id: ''", True),
|
||||
],
|
||||
ids=[
|
||||
"follow_correct",
|
||||
"like_correct",
|
||||
"save_correct",
|
||||
"follow_german",
|
||||
"like_german",
|
||||
"follow_reel_poison",
|
||||
"like_photo_poison",
|
||||
"save_comment_poison",
|
||||
"back_non_toggle",
|
||||
"abstract_non_toggle",
|
||||
],
|
||||
)
|
||||
def test_intent_matches_node(self, intent, semantic_string, expected):
|
||||
result = _intent_matches_node(intent, semantic_string)
|
||||
assert result is expected, (
|
||||
f"_intent_matches_node('{intent}', '{semantic_string[:50]}...') "
|
||||
f"returned {result}, expected {expected}"
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# CONTRACT 5: ActionMemory Lifecycle — Track → Verify → Confirm/Reject
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
#
|
||||
# This is the full lifecycle that runs for EVERY click in production.
|
||||
# If any step in this chain is broken, the bot either:
|
||||
# - Accepts hallucinated clicks (memory poisoning)
|
||||
# - Rejects correct clicks (performance death)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestActionMemoryLifecycle:
|
||||
"""Tests the full click tracking → confirmation/rejection lifecycle."""
|
||||
|
||||
def _make_memory(self):
|
||||
"""Create ActionMemory with a FakeUIMemoryDB."""
|
||||
fake_db = FakeUIMemoryDB()
|
||||
return ActionMemory(ui_memory=fake_db), fake_db
|
||||
|
||||
def test_confirm_correct_follow_stores_in_memory(self):
|
||||
"""A confirmed 'follow' click on a Follow button must be stored."""
|
||||
memory, fake_db = self._make_memory()
|
||||
node = _make_node(resource_id="follow_button", content_desc="Follow")
|
||||
memory.track_click("follow", node)
|
||||
memory.confirm_click("follow")
|
||||
assert len(fake_db.store_memory_calls) == 1
|
||||
|
||||
def test_confirm_poisoned_follow_is_blocked(self):
|
||||
"""
|
||||
Production bug: VLM clicked a Reel thumbnail for 'follow'.
|
||||
The semantic guard must BLOCK this from being stored in memory.
|
||||
"""
|
||||
memory, fake_db = self._make_memory()
|
||||
node = _make_node(
|
||||
resource_id="com.instagram.android:id/preview_clip_thumbnail",
|
||||
content_desc="Reel by trenny_m. View Count 3.143",
|
||||
)
|
||||
memory.track_click("follow", node)
|
||||
memory.confirm_click("follow")
|
||||
# Must NOT have stored this poisoned memory
|
||||
assert len(fake_db.store_memory_calls) == 0
|
||||
|
||||
def test_reject_click_triggers_decay(self):
|
||||
"""A rejected click must trigger confidence decay."""
|
||||
memory, fake_db = self._make_memory()
|
||||
node = _make_node(content_desc="Back")
|
||||
memory.track_click("tap back button", node)
|
||||
memory.reject_click("tap back button")
|
||||
assert len(fake_db.decay_confidence_calls) == 1
|
||||
|
||||
def test_confirm_without_track_is_noop(self):
|
||||
"""Confirming without tracking must not crash or store."""
|
||||
memory, fake_db = self._make_memory()
|
||||
memory.confirm_click("follow") # No prior track_click
|
||||
assert len(fake_db.store_memory_calls) == 0
|
||||
|
||||
def test_mismatched_intent_is_ignored(self):
|
||||
"""Confirming with a different intent than tracked must be ignored."""
|
||||
memory, fake_db = self._make_memory()
|
||||
node = _make_node(content_desc="Follow")
|
||||
memory.track_click("follow", node)
|
||||
memory.confirm_click("like") # Wrong intent
|
||||
assert len(fake_db.store_memory_calls) == 0
|
||||
|
||||
def test_verify_follow_success_markers(self):
|
||||
"""
|
||||
After a follow click, the post-click XML must contain
|
||||
'following' or 'requested' to count as success.
|
||||
"""
|
||||
memory, _ = self._make_memory()
|
||||
|
||||
post_xml_success = '<node text="Following" />'
|
||||
result = memory.verify_success(
|
||||
"follow", pre_click_xml="<node/>", post_click_xml=post_xml_success
|
||||
)
|
||||
assert result is True
|
||||
|
||||
def test_verify_view_post_success(self):
|
||||
"""After tapping a grid item, post detail markers must appear."""
|
||||
memory, _ = self._make_memory()
|
||||
|
||||
post_xml_success = '<node resource-id="com.instagram.android:id/row_feed_button_like" />'
|
||||
result = memory.verify_success(
|
||||
"view a post", pre_click_xml="<node/>", post_click_xml=post_xml_success
|
||||
)
|
||||
assert result is True
|
||||
Reference in New Issue
Block a user