Files
instagram-bot/tests/e2e/conftest.py

374 lines
15 KiB
Python

"""
E2E Test Configuration — Hardened Test Infrastructure
======================================================
Design Principles:
1. No module-level mutable state (VirtualClock is a fixture, not a global)
2. No sys.modules poisoning (Qdrant mock via monkeypatch)
3. Unified fixture loading from a single source of truth
4. Global timeout to prevent infinite hangs in mocked loops
5. Deterministic loop termination via MaxIterationGuard
"""
import os
import signal
import time
import pytest
from GramAddict.core import utils
# ═══════════════════════════════════════════════════════
# CLI Options
# ═══════════════════════════════════════════════════════
def pytest_addoption(parser):
parser.addoption("--live", action="store_true", default=False, help="Run live tests")
# ═══════════════════════════════════════════════════════
# Constants
# ═══════════════════════════════════════════════════════
E2E_TEST_TIMEOUT_SECONDS = 300
FIXTURES_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), "fixtures")
E2E_FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
# ═══════════════════════════════════════════════════════
# Fixture Loading — Single Source of Truth
# ═══════════════════════════════════════════════════════
def load_fixture_xml(filename: str) -> str:
"""Load an XML fixture file. Checks e2e/fixtures first, then tests/fixtures.
Raises pytest.fail with a clear message if the fixture is missing.
"""
for fix_dir in (E2E_FIXTURES_DIR, FIXTURES_DIR):
path = os.path.join(fix_dir, filename)
if os.path.exists(path):
with open(path, "r", encoding="utf-8") as f:
return f.read()
pytest.fail(
f"MISSING REAL DUMP: '{filename}' not found in:\n"
f" - {E2E_FIXTURES_DIR}\n"
f" - {FIXTURES_DIR}\n"
f"Capture it using: python3 scripts/sync_fixtures.py --fixture {filename}",
pytrace=False,
)
# ═══════════════════════════════════════════════════════
# Global Test Timeout — Prevents Infinite Hangs
# ═══════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def e2e_test_timeout():
"""Hard timeout for every E2E test. Prevents mocked loops from hanging forever."""
def _timeout_handler(signum, frame):
pytest.fail(
f"E2E TEST TIMEOUT: Test exceeded {E2E_TEST_TIMEOUT_SECONDS}s. "
f"This almost certainly means the test entered an infinite loop "
f"due to exhausted mock side_effects or missing loop guards.",
pytrace=True,
)
old_handler = signal.signal(signal.SIGALRM, _timeout_handler)
signal.alarm(E2E_TEST_TIMEOUT_SECONDS)
yield
signal.alarm(E2E_TEST_TIMEOUT_SECONDS)
signal.signal(signal.SIGALRM, old_handler)
# ═══════════════════════════════════════════════════════
# MaxIterationGuard — Deterministic Loop Termination
# ═══════════════════════════════════════════════════════
class MaxIterationGuard:
"""Prevents infinite loops in tests by counting iterations.
Usage:
guard = MaxIterationGuard(50, "feed loop")
while not done:
guard.tick() # Raises after 50 ticks
"""
def __init__(self, max_iterations: int, context: str = "unknown"):
self.max_iterations = max_iterations
self.context = context
self._count = 0
def tick(self):
self._count += 1
if self._count > self.max_iterations:
pytest.fail(
f"INFINITE LOOP DETECTED in '{self.context}': "
f"Exceeded {self.max_iterations} iterations. "
f"Fix the mock setup — the loop has no natural exit condition.",
pytrace=True,
)
@property
def count(self) -> int:
return self._count
@pytest.fixture
def iteration_guard():
"""Factory fixture for creating MaxIterationGuards."""
def _factory(max_iterations: int = 100, context: str = "e2e_loop"):
return MaxIterationGuard(max_iterations, context)
return _factory
# ═══════════════════════════════════════════════════════
# Real Qdrant DB (Isolated Collection)
# ═══════════════════════════════════════════════════════
@pytest.fixture(scope="function", autouse=True)
def isolated_screen_memory():
"""Ensures we use a separate Qdrant collection for E2E tests and clean it.
This replaces the old Qdrant mock so tests use the REAL database."""
from GramAddict.core.qdrant_memory import ScreenMemoryDB
original_init = ScreenMemoryDB.__init__
def test_init(self, *args, **kwargs):
super(ScreenMemoryDB, self).__init__(collection_name="test_e2e_screens")
ScreenMemoryDB.__init__ = test_init
db = ScreenMemoryDB()
if db.is_connected:
db.wipe_collection()
yield db
# Restore original
ScreenMemoryDB.__init__ = original_init
# ═══════════════════════════════════════════════════════
# Device Dump Injectors
# ═══════════════════════════════════════════════════════
@pytest.fixture
def e2e_device_dump_injector(request):
"""Provides a factory to mock device.dump_hierarchy using real XML files."""
if request.config.getoption("--live"):
return lambda *args, **kwargs: None
def _inject_dump(device_mock, xml_filename):
real_xml = load_fixture_xml(xml_filename)
device_mock.dump_hierarchy.return_value = real_xml
return real_xml
return _inject_dump
# ═══════════════════════════════════════════════════════
# Delay Mocking — Uses Fixture-Scoped Clock
# ═══════════════════════════════════════════════════════
def _patch_module_delays(monkeypatch, module_path: str, sleep_fn, random_sleep_fn):
"""Safely patch sleep/random in a single module. Missing attributes are skipped."""
import importlib
try:
mod = importlib.import_module(module_path)
except ImportError:
return # Module doesn't exist, nothing to patch
if hasattr(mod, "sleep"):
monkeypatch.setattr(mod, "sleep", sleep_fn)
if hasattr(mod, "random_sleep"):
monkeypatch.setattr(mod, "random_sleep", random_sleep_fn)
if hasattr(mod, "random") and hasattr(mod.random, "uniform"):
monkeypatch.setattr(mod.random, "uniform", lambda a, b: float(a))
@pytest.fixture(autouse=True)
def mock_all_delays(monkeypatch, request):
"""Replaces all humanized hardware delays with no-ops."""
if request.config.getoption("--live"):
return
def money_sleep(*args, **kwargs):
pass
def random_sleep(*args, **kwargs):
pass
monkeypatch.setattr(time, "sleep", money_sleep)
monkeypatch.setattr(utils, "random_sleep", random_sleep)
monkeypatch.setattr(utils, "sleep", money_sleep)
if hasattr(utils, "random"):
monkeypatch.setattr(utils.random, "uniform", lambda a, b: float(a))
# Each module gets its own try-block so a missing attribute in one
# doesn't prevent patching the others.
_patch_module_delays(monkeypatch, "GramAddict.core.bot_flow", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.q_nav_graph", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.goap", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.device_facade", money_sleep, random_sleep)
_patch_module_delays(monkeypatch, "GramAddict.core.darwin_engine", money_sleep, random_sleep)
# Standardize DarwinEngine to prevent mockup math errors on session end
try:
from GramAddict.core.darwin_engine import DarwinEngine
monkeypatch.setattr(DarwinEngine, "evaluate_session_end", lambda *args, **kwargs: None)
except ImportError:
pass
# ═══════════════════════════════════════════════════════
# Identity & Account Guard
# ═══════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def mock_identity_guard(monkeypatch):
import GramAddict.core.bot_flow
monkeypatch.setattr(
GramAddict.core.bot_flow,
"verify_and_switch_account",
lambda *args, **kwargs: True,
)
# ═══════════════════════════════════════════════════════
# E2E Configs — Standardized Test Configuration
# ═══════════════════════════════════════════════════════
@pytest.fixture
def e2e_configs():
import argparse
args = argparse.Namespace(
username="testuser",
device="emulator-5554",
app_id="com.instagram.android",
debug=True,
feed=None,
carousel_percentage=0,
carousel_count="1",
explore=None,
reels=None,
stories=None,
interact_percentage=100,
likes_count="2-3",
likes_percentage=100,
follow_percentage=100,
comment_percentage=100,
stories_count="1-2",
stories_percentage=100,
working_hours=[0.0, 24.0],
time_delta_session=0,
speed_multiplier=1.0,
disable_filters=False,
interaction_users_amount="1",
scrape_profiles=False,
disable_ai_messaging=True,
total_unfollows_limit=0,
ai_telepathic_url="http://localhost",
ai_telepathic_model="llama3",
ai_condenser_url="http://localhost",
dry_run_comments=False,
visual_vibe_check_percentage=0,
)
class DummyConfig:
def __init__(self, args_ns):
self.args = args_ns
self.username = "testuser"
self.plugins = {}
def get_plugin_config(self, plugin_name):
mapping = {
"likes": {"count": self.args.likes_count, "percentage": self.args.likes_percentage},
"comment": {
"percentage": self.args.comment_percentage,
"dry_run": self.args.dry_run_comments,
},
"follow": {"percentage": self.args.follow_percentage},
"stories": {
"count": self.args.stories_count,
"percentage": self.args.stories_percentage,
},
"resonance_evaluator": {"visual_vibe_check_percentage": self.args.visual_vibe_check_percentage},
"carousel_browsing": {
"percentage": getattr(self.args, "carousel_percentage", 0),
"count": getattr(self.args, "carousel_count", "1"),
},
}
return mapping.get(plugin_name, {})
return DummyConfig(args)
# ═══════════════════════════════════════════════════════
# Plugin Registry — Standard Setup
# ═══════════════════════════════════════════════════════
@pytest.fixture(autouse=True)
def setup_e2e_plugin_registry():
"""Ensures that all standard plugins are registered for E2E tests."""
from GramAddict.core.behaviors import PluginRegistry
from GramAddict.core.behaviors.ad_guard import AdGuardPlugin
from GramAddict.core.behaviors.anomaly_handler import AnomalyHandlerPlugin
from GramAddict.core.behaviors.carousel_browsing import CarouselBrowsingPlugin
from GramAddict.core.behaviors.close_friends_guard import CloseFriendsGuardPlugin
from GramAddict.core.behaviors.comment import CommentPlugin
from GramAddict.core.behaviors.darwin_dwell import DarwinDwellPlugin
from GramAddict.core.behaviors.follow import FollowPlugin
from GramAddict.core.behaviors.grid_like import GridLikePlugin
from GramAddict.core.behaviors.like import LikePlugin
from GramAddict.core.behaviors.obstacle_guard import ObstacleGuardPlugin
from GramAddict.core.behaviors.perfect_snapping import PerfectSnappingPlugin
from GramAddict.core.behaviors.post_data_extraction import PostDataExtractionPlugin
from GramAddict.core.behaviors.post_interaction import PostInteractionPlugin
from GramAddict.core.behaviors.profile_guard import ProfileGuardPlugin
from GramAddict.core.behaviors.profile_visit import ProfileVisitPlugin
from GramAddict.core.behaviors.rabbit_hole import RabbitHolePlugin
from GramAddict.core.behaviors.repost import RepostPlugin
from GramAddict.core.behaviors.resonance_evaluator import ResonanceEvaluatorPlugin
from GramAddict.core.behaviors.story_view import StoryViewPlugin
PluginRegistry.reset()
plugin_registry = PluginRegistry.get_instance()
plugin_registry.register(ProfileGuardPlugin())
plugin_registry.register(StoryViewPlugin())
plugin_registry.register(FollowPlugin())
plugin_registry.register(GridLikePlugin())
plugin_registry.register(CarouselBrowsingPlugin())
plugin_registry.register(AdGuardPlugin())
plugin_registry.register(CloseFriendsGuardPlugin())
plugin_registry.register(AnomalyHandlerPlugin())
plugin_registry.register(ObstacleGuardPlugin())
plugin_registry.register(PerfectSnappingPlugin())
plugin_registry.register(PostDataExtractionPlugin())
plugin_registry.register(ResonanceEvaluatorPlugin())
plugin_registry.register(RabbitHolePlugin())
plugin_registry.register(DarwinDwellPlugin())
plugin_registry.register(ProfileVisitPlugin())
plugin_registry.register(LikePlugin())
plugin_registry.register(CommentPlugin())
plugin_registry.register(RepostPlugin())
plugin_registry.register(PostInteractionPlugin())
yield plugin_registry